diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ce2acd5 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, type ReactNode } from "react"; +import { notify } from "../lib/notifications"; + +interface Props { + children: ReactNode; +} +interface State { + crashed: boolean; +} + +// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept +// in the center across the reload) and offers a recovery action. +export default class ErrorBoundary extends Component { + state: State = { crashed: false }; + + static getDerivedStateFromError(): State { + return { crashed: true }; + } + + componentDidCatch(error: Error) { + notify({ + level: "fatal", + title: "Something broke", + message: error.message || "Unexpected error", + requiresInteraction: true, + }); + } + + render() { + if (this.state.crashed) { + return ( +
+
+
Something went wrong.
+
The app hit an unexpected error.
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a1c8c92..584bfb3 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/toast"; +import { toast } from "../lib/notifications"; import VideoCard from "./VideoCard"; const PAGE = 60; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f0bc19a..1359e42 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -12,6 +12,7 @@ import { import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import type { FeedFilters, Me } from "../lib/api"; import SyncStatus from "./SyncStatus"; +import NotificationCenter from "./NotificationCenter"; function IconBtn(props: React.ButtonHTMLAttributes) { const { className = "", ...rest } = props; @@ -130,6 +131,8 @@ export default function Header({ )} + +
diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..d0bd91c --- /dev/null +++ b/frontend/src/components/NotificationCenter.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from "react"; +import { useSyncExternalStore } from "react"; +import { Bell, Trash2 } from "lucide-react"; +import { + clearAll, + dismiss, + getNotifications, + getUnreadCount, + markAllRead, + subscribe, +} from "../lib/notifications"; +import { LEVEL_STYLE } from "./Toaster"; + +function relTime(ts: number): string { + const s = Math.round((Date.now() - ts) / 1000); + if (s < 60) return "just now"; + const m = Math.round(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + return `${d}d ago`; +} + +export default function NotificationCenter() { + const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications); + const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); + const [open, setOpen] = useState(false); + const wrap = useRef(null); + + // Opening the center marks everything read. + useEffect(() => { + if (open) markAllRead(); + }, [open, notifications.length]); + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+
+
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 + + )} +
+ {n.action && !n.dismissed && ( + + )} +
+
+ ); + }) + )} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 58a653a..7447eed 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -1,29 +1,68 @@ import { useSyncExternalStore } from "react"; -import { dismiss, getToasts, subscribe } from "../lib/toast"; +import { + AlertCircle, + AlertTriangle, + CheckCircle2, + Info, + X, + XCircle, +} from "lucide-react"; +import { + dismiss, + getActiveToasts, + subscribe, + type NotifLevel, +} from "../lib/notifications"; + +export const LEVEL_STYLE: Record< + NotifLevel, + { icon: typeof Info; color: 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" }, +}; export default function Toaster() { - const toasts = useSyncExternalStore(subscribe, getToasts, getToasts); + const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); + return ( -
- {toasts.map((t) => ( -
- {t.message} - {t.action && ( +
+ {toasts.map((t) => { + const { icon: Icon, color } = LEVEL_STYLE[t.level]; + return ( +
+ +
+ {t.title &&
{t.title}
} +
{t.message}
+ {t.action && ( + + )} +
- )} -
- ))} +
+ ); + })}
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3fec3fe..093ca15 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,3 +1,5 @@ +import { notify } from "./notifications"; + export interface Me { id: number; email: string; @@ -67,12 +69,34 @@ class HttpError extends Error { } async function req(url: string, opts: RequestInit = {}): Promise { - const r = await fetch(url, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...opts, - }); - if (!r.ok) throw new HttpError(r.status); + const method = opts.method ?? "GET"; + let r: Response; + try { + r = await fetch(url, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...opts, + }); + } catch (e) { + // Network/connection failure — surface it in the notification center. + notify({ + level: "error", + title: "Network error", + message: `Couldn't reach the server (${method} ${url}).`, + }); + throw e; + } + if (!r.ok) { + // Server faults are worth logging; 401/403/404 etc. are handled by callers. + if (r.status >= 500) { + notify({ + level: "error", + title: `Server error ${r.status}`, + message: `${method} ${url}`, + }); + } + throw new HttpError(r.status); + } return r.status === 204 ? null : r.json(); } diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts new file mode 100644 index 0000000..579d2bc --- /dev/null +++ b/frontend/src/lib/notifications.ts @@ -0,0 +1,140 @@ +// Client-side notification store. Surfaces transient toasts AND keeps a history that +// the Notification Center shows (info events, actions awaiting interaction, and app +// errors). History is persisted to localStorage so it survives reloads; action +// callbacks are live-only and dropped on reload. + +export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; + +export interface NotifAction { + label: string; + onClick: () => void; +} + +export interface Notification { + id: number; + level: NotifLevel; + title?: string; + message: string; + action?: NotifAction; + requiresInteraction: boolean; + ts: number; + read: boolean; + dismissed: boolean; // transient toast surface closed (still kept in history) +} + +export interface NotifyInput { + message: string; + level?: NotifLevel; + title?: string; + action?: NotifAction; + requiresInteraction?: boolean; +} + +const HISTORY_KEY = "subfeed.notifications"; +const MAX_HISTORY = 100; +const DEFAULT_TTL = 9000; +const ERROR_TTL = 15000; + +let items: Notification[] = load(); +let listeners: Array<() => void> = []; +let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; + +// Cached derived snapshots — useSyncExternalStore needs stable references between +// emits, so we recompute these only when `items` changes. +let cachedActive: Notification[] = []; +let cachedReversed: Notification[] = []; +let cachedUnread = 0; + +function load(): Notification[] { + try { + const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); + if (!Array.isArray(raw)) return []; + return raw.map((n) => ({ ...n, action: undefined }) as Notification); + } catch { + return []; + } +} + +function persist() { + try { + const slim = items.map(({ action: _action, ...n }) => n); + localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); + } catch { + /* ignore quota / serialization errors */ + } +} + +function recompute() { + cachedActive = items.filter((n) => !n.dismissed); + cachedReversed = [...items].reverse(); + cachedUnread = items.reduce((c, n) => c + (n.read ? 0 : 1), 0); +} + +function emit() { + items = items.slice(-MAX_HISTORY); + recompute(); + persist(); + listeners.forEach((l) => l()); +} + +recompute(); + +export function subscribe(listener: () => void): () => void { + listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; +} + +export function notify(input: NotifyInput): number { + const id = counter++; + const requiresInteraction = input.requiresInteraction ?? false; + const level = input.level ?? "info"; + items = [ + ...items, + { + id, + level, + title: input.title, + message: input.message, + action: input.action, + requiresInteraction, + ts: Date.now(), + read: false, + dismissed: false, + }, + ]; + emit(); + if (!requiresInteraction) { + const ttl = level === "error" || level === "fatal" ? ERROR_TTL : DEFAULT_TTL; + setTimeout(() => dismiss(id), ttl); + } + return id; +} + +/** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */ +export function toast(message: string, action?: NotifAction): number { + return notify({ message, action }); +} + +/** Close the transient toast surface; the entry stays in the center's history. */ +export function dismiss(id: number): void { + items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n)); + emit(); +} + +export function markAllRead(): void { + if (items.every((n) => n.read)) return; + items = items.map((n) => (n.read ? n : { ...n, read: true })); + emit(); +} + +export function clearAll(): void { + if (items.length === 0) return; + items = []; + emit(); +} + +export const getActiveToasts = (): Notification[] => cachedActive; +export const getNotifications = (): Notification[] => cachedReversed; +export const getUnreadCount = (): number => cachedUnread; diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts deleted file mode 100644 index 8a35638..0000000 --- a/frontend/src/lib/toast.ts +++ /dev/null @@ -1,41 +0,0 @@ -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), 9000); - 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); - }; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 17aa397..4ec3461 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; +import ErrorBoundary from "./components/ErrorBoundary"; import "./index.css"; const queryClient = new QueryClient({ @@ -11,7 +12,9 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - + + + );