fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed flag, scheduled refinement (migration 0005) - Search: match title + channel name only (descriptions caused noisy results) - Faceted tag filtering: AND across categories (language AND topic narrows), OR within a category; any/all toggle applies to topics - Language detection: majority vote over individual titles (fixes misdetections like multipoleguy -> English; drops bogus Polish/Romanian) - Login: drop forced consent so returning sign-in is quick (select_account) - Feed cards: clickable channel name (opens channel), persistent saved badge, undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
This commit is contained in:
parent
e56502789f
commit
8c245e986f
17 changed files with 306 additions and 35 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import VideoCard from "./VideoCard";
|
||||
|
||||
const PAGE = 60;
|
||||
|
|
@ -43,12 +44,19 @@ export default function Feed({
|
|||
function onState(id: string, status: string) {
|
||||
setOverrides((o) => ({ ...o, [id]: status }));
|
||||
api.setState(id, status).catch(() => {});
|
||||
if (status === "hidden") {
|
||||
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
|
||||
}
|
||||
}
|
||||
|
||||
const items: Video[] = (query.data?.pages ?? [])
|
||||
.flatMap((p) => p.items)
|
||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||
.filter((v) => overrides[v.id] !== "hidden");
|
||||
.filter((v) => {
|
||||
const ov = overrides[v.id];
|
||||
// In the Hidden view, drop just-unhidden items; elsewhere drop just-hidden ones.
|
||||
return filters.show === "hidden" ? ov !== "new" : ov !== "hidden";
|
||||
});
|
||||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
||||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const SHOWS = [
|
|||
{ id: "all", label: "All" },
|
||||
{ id: "watched", label: "Watched" },
|
||||
{ id: "saved", label: "Saved" },
|
||||
{ id: "hidden", label: "Hidden" },
|
||||
];
|
||||
|
||||
function TagChip({
|
||||
|
|
@ -29,6 +30,7 @@ function TagChip({
|
|||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
|
|
@ -36,9 +38,6 @@ function TagChip({
|
|||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<span className={active ? "opacity-80 ml-1" : "text-muted ml-1"}>
|
||||
{tag.channel_count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/components/Toaster.tsx
Normal file
29
frontend/src/components/Toaster.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import { dismiss, getToasts, subscribe } from "../lib/toast";
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useSyncExternalStore(subscribe, getToasts, getToasts);
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="bg-surface border border-border rounded-xl shadow-2xl px-4 py-3 flex items-center gap-4 animate-[fadeIn_0.15s_ease]"
|
||||
>
|
||||
<span className="text-sm">{t.message}</span>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
className="text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,6 +80,11 @@ function Thumb({ video, className }: { video: Video; className?: string }) {
|
|||
stream
|
||||
</span>
|
||||
)}
|
||||
{video.status === "saved" && (
|
||||
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,7 +127,15 @@ export default function VideoCard({
|
|||
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</div>
|
||||
<Actions video={video} onState={onState} />
|
||||
|
|
@ -144,7 +157,15 @@ export default function VideoCard({
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
<Actions video={video} onState={onState} />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue