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:
npeter83 2026-06-11 03:07:49 +02:00
parent e56502789f
commit 8c245e986f
17 changed files with 306 additions and 35 deletions

View file

@ -12,6 +12,7 @@ import Login from "./components/Login";
import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import Toaster from "./components/Toaster";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
@ -83,6 +84,7 @@ export default function App() {
<Feed filters={filters} view={view} />
</main>
</div>
<Toaster />
</div>
);
}

View file

@ -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>;

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -22,6 +22,7 @@ export interface Video {
channel_id: string;
channel_title: string | null;
channel_thumbnail: string | null;
channel_url: string;
published_at: string | null;
thumbnail_url: string | null;
duration_seconds: number | null;

41
frontend/src/lib/toast.ts Normal file
View file

@ -0,0 +1,41 @@
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastItem {
id: number;
message: string;
action?: ToastAction;
}
let items: ToastItem[] = [];
let listeners: Array<() => void> = [];
let counter = 1;
function emit() {
listeners.forEach((l) => l());
}
export function toast(message: string, action?: ToastAction): number {
const id = counter++;
items = [...items, { id, message, action }];
emit();
setTimeout(() => dismiss(id), 6000);
return id;
}
export function dismiss(id: number) {
items = items.filter((i) => i.id !== id);
emit();
}
export function getToasts(): ToastItem[] {
return items;
}
export function subscribe(listener: () => void): () => void {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}