fix: feedback round 2 — language, subscriptions, feed scope, UX

- Language detection: classify one cleaned+concatenated blob (strip emoji,
  @mentions, #tags, numbers, punctuation); fixes caps/emoji-heavy channels
  (e.g. Nessaj -> Hungarian, no more bogus Chinese/Korean)
- Feed now joins the user's subscriptions, so unsubscribing on YouTube removes a
  channel from the feed; periodic subscription re-sync job picks up changes
- Watched/Saved/Hidden views ignore the Shorts/live default-hiding so the full
  set is visible (fixes hidden videos missing from the Hidden view)
- Persist feed filters + search across reloads (localStorage)
- 3D polish: cards lift with shadow on hover; chips/buttons get depth and a
  press effect; undo toast lasts longer
This commit is contained in:
npeter83 2026-06-11 03:28:45 +02:00
parent 8c245e986f
commit e07a37622d
9 changed files with 97 additions and 27 deletions

View file

@ -24,11 +24,26 @@ const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched",
};
const FILTERS_KEY = "subfeed.filters";
function loadFilters(): FeedFilters {
try {
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
} catch {
return DEFAULT_FILTERS;
}
}
export default function App() {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFilters] = useState<FeedFilters>(DEFAULT_FILTERS);
const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
const [view, setView] = useState<"grid" | "list">("grid");
function setFilters(next: FeedFilters) {
setFiltersState(next);
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
}
useEffect(() => applyTheme(theme), [theme]);
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });

View file

@ -31,7 +31,7 @@ function TagChip({
<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 ${
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent"
@ -77,7 +77,7 @@ export default function Sidebar({
<button
key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })}
className={`text-xs py-1.5 rounded-lg border transition ${
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === s.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"

View file

@ -56,7 +56,7 @@ function Thumb({ video, className }: { video: Video; className?: string }) {
rel="noreferrer"
onClick={() => video.status === "new" && undefined}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border",
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className
)}
>
@ -120,7 +120,7 @@ export default function VideoCard({
return (
<div
className={clsx(
"group flex gap-3 p-2 rounded-xl hover:bg-card transition",
"group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
@ -144,7 +144,12 @@ export default function VideoCard({
}
return (
<div className={clsx("group", watched && "opacity-55")}>
<div
className={clsx(
"group transition-transform duration-150 hover:-translate-y-1",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" />
<div className="flex gap-3 mt-2">
{video.channel_thumbnail && (

View file

@ -20,7 +20,7 @@ export function toast(message: string, action?: ToastAction): number {
const id = counter++;
items = [...items, { id, message, action }];
emit();
setTimeout(() => dismiss(id), 6000);
setTimeout(() => dismiss(id), 9000);
return id;
}