From a419ac29433f64d7470c0e95cf26d3ea108b279a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 19:46:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20notification=20UX=20=E2=80=94=20outs?= =?UTF-8?q?ide-click=20close,=20toast=20countdown,=20hide=20entry=20action?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationCenter closes on outside click (document listener, not an overlay that the blurred header trapped) and no longer needs a second bell click. - Toasts show a level-colored countdown bar and auto-dismiss faster (6s default). - Hidden-video notifications carry structured meta so the center offers an in-app 'Find in feed' (jump to that channel's hidden videos) and a one-click 'Unhide', working even after a reload when the live Undo callback is gone. --- frontend/src/components/Feed.tsx | 15 +- frontend/src/components/Header.tsx | 2 +- .../src/components/NotificationCenter.tsx | 168 ++++++++++++------ frontend/src/components/Toaster.tsx | 22 ++- frontend/src/index.css | 10 ++ frontend/src/lib/notifications.ts | 27 ++- 6 files changed, 173 insertions(+), 71 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 584bfb3..747149b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; -import { toast } from "../lib/notifications"; +import { notify } from "../lib/notifications"; import VideoCard from "./VideoCard"; const PAGE = 60; @@ -73,7 +73,18 @@ export default function Feed({ .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .catch(() => {}); if (status === "hidden") { - toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") }); + const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id); + notify({ + message: v?.title ? `Hidden “${v.title}”` : "Video hidden", + action: { label: "Undo", onClick: () => onState(id, "new") }, + meta: { + kind: "video-hidden", + videoId: id, + title: v?.title ?? "this video", + channelId: v?.channel_id ?? "", + channelName: v?.channel_title ?? "", + }, + }); } } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1359e42..46f73c0 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -131,7 +131,7 @@ export default function Header({ )} - +
diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index d0bd91c..38bf67c 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -1,13 +1,16 @@ -import { useEffect, useRef, useState } from "react"; -import { useSyncExternalStore } from "react"; -import { Bell, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Bell, Eye, Search, Trash2 } from "lucide-react"; +import { api, type FeedFilters } from "../lib/api"; import { clearAll, dismiss, getNotifications, getUnreadCount, markAllRead, + notify, subscribe, + type NotifMeta, } from "../lib/notifications"; import { LEVEL_STYLE } from "./Toaster"; @@ -22,17 +25,55 @@ function relTime(ts: number): string { return `${d}d ago`; } -export default function NotificationCenter() { +export default function NotificationCenter({ + filters, + setFilters, +}: { + filters: FeedFilters; + setFilters: (f: FeedFilters) => void; +}) { const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications); const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const [open, setOpen] = useState(false); const wrap = useRef(null); + const qc = useQueryClient(); + + // Close when clicking anywhere outside the panel. + useEffect(() => { + if (!open) return; + function onDown(e: MouseEvent) { + if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); // Opening the center marks everything read. useEffect(() => { if (open) markAllRead(); }, [open, notifications.length]); + function locateHidden(meta: NotifMeta) { + setFilters({ + ...filters, + show: "hidden", + channelId: meta.channelId || undefined, + channelName: meta.channelName || undefined, + }); + setOpen(false); + } + + function unhide(meta: NotifMeta) { + api + .setState(meta.videoId, "new") + .then(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["feed-count"] }); + notify({ level: "success", message: `Unhidden “${meta.title}”` }); + }) + .catch(() => {}); + } + return (
- )} -
+
+
+
Notifications
+ {notifications.length > 0 && ( + + )} +
-
- {notifications.length === 0 ? ( -
- No notifications yet. -
- ) : ( - notifications.map((n) => { - const { icon: Icon, color } = LEVEL_STYLE[n.level]; - const needsAction = n.requiresInteraction && !n.dismissed; - return ( -
- -
- {n.title &&
{n.title}
} -
{n.message}
-
- {relTime(n.ts)} - {needsAction && ( - - Action needed - - )} +
+ {notifications.length === 0 ? ( +
No notifications yet.
+ ) : ( + notifications.map((n) => { + const { icon: Icon, color } = LEVEL_STYLE[n.level]; + const needsAction = n.requiresInteraction && !n.dismissed; + const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; + return ( +
+ +
+ {n.title &&
{n.title}
} +
{n.message}
+
+ {relTime(n.ts)} + {needsAction && ( + + Action needed + + )} +
+ + {hidden ? ( +
+ +
- {n.action && !n.dismissed && ( + ) : ( + n.action && + !n.dismissed && ( - )} -
+ ) + )}
- ); - }) - )} -
+
+ ); + }) + )}
- +
)}
); diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 7447eed..e08e6b3 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -16,13 +16,13 @@ import { export const LEVEL_STYLE: Record< NotifLevel, - { icon: typeof Info; color: string } + { icon: typeof Info; color: string; bar: string } > = { - info: { icon: Info, color: "text-accent" }, - success: { icon: CheckCircle2, color: "text-emerald-400" }, - warning: { icon: AlertTriangle, color: "text-amber-400" }, - error: { icon: AlertCircle, color: "text-red-400" }, - fatal: { icon: XCircle, color: "text-red-500" }, + info: { icon: Info, color: "text-accent", bar: "bg-accent" }, + success: { icon: CheckCircle2, color: "text-emerald-400", bar: "bg-emerald-400" }, + warning: { icon: AlertTriangle, color: "text-amber-400", bar: "bg-amber-400" }, + error: { icon: AlertCircle, color: "text-red-400", bar: "bg-red-400" }, + fatal: { icon: XCircle, color: "text-red-500", bar: "bg-red-500" }, }; export default function Toaster() { @@ -31,11 +31,11 @@ export default function Toaster() { return (
{toasts.map((t) => { - const { icon: Icon, color } = LEVEL_STYLE[t.level]; + const { icon: Icon, color, bar } = LEVEL_STYLE[t.level]; return (
@@ -60,6 +60,12 @@ export default function Toaster() { > + {t.duration && ( +
+ )}
); })} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1d4ce62..e9e3683 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -115,6 +115,16 @@ html[data-scheme="youtube"][data-theme="light"] { --accent-fg: #ffffff; } +/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */ +@keyframes toastbar { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + /* Thin, theme-aware scrollbars */ * { scrollbar-color: var(--border) transparent; diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 579d2bc..71f25a2 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -10,13 +10,25 @@ export interface NotifAction { onClick: () => void; } +// Structured payload that lets the Notification Center offer in-app actions even +// after a reload (when live `action` callbacks are gone). +export type NotifMeta = { + kind: "video-hidden"; + videoId: string; + title: string; + channelId: string; + channelName: string; +}; + export interface Notification { id: number; level: NotifLevel; title?: string; message: string; action?: NotifAction; + meta?: NotifMeta; requiresInteraction: boolean; + duration?: number; // auto-dismiss ms (undefined when it stays until acted on) ts: number; read: boolean; dismissed: boolean; // transient toast surface closed (still kept in history) @@ -27,12 +39,13 @@ export interface NotifyInput { level?: NotifLevel; title?: string; action?: NotifAction; + meta?: NotifMeta; requiresInteraction?: boolean; } const HISTORY_KEY = "subfeed.notifications"; const MAX_HISTORY = 100; -const DEFAULT_TTL = 9000; +const DEFAULT_TTL = 6000; const ERROR_TTL = 15000; let items: Notification[] = load(); @@ -90,6 +103,11 @@ export function notify(input: NotifyInput): number { const id = counter++; const requiresInteraction = input.requiresInteraction ?? false; const level = input.level ?? "info"; + const duration = requiresInteraction + ? undefined + : level === "error" || level === "fatal" + ? ERROR_TTL + : DEFAULT_TTL; items = [ ...items, { @@ -98,17 +116,16 @@ export function notify(input: NotifyInput): number { title: input.title, message: input.message, action: input.action, + meta: input.meta, requiresInteraction, + duration, ts: Date.now(), read: false, dismissed: false, }, ]; emit(); - if (!requiresInteraction) { - const ttl = level === "error" || level === "fatal" ? ERROR_TTL : DEFAULT_TTL; - setTimeout(() => dismiss(id), ttl); - } + if (duration) setTimeout(() => dismiss(id), duration); return id; }