import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, CheckCheck, Trash2, X } from "lucide-react"; import { api, type AppNotification } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; // The durable, server-backed notification center (inbox phase 1). It COEXISTS with the // client-side transient bell in the nav rail: the bell is a quick peek at recent toasts kept // in localStorage, this is the full, filterable, clearable history synced from the server. const POLL_MS = 8000; function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { if (!iso) return ""; const then = new Date(iso).getTime(); const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. if (secs < 60) return t("notifications.time.justNow"); const mins = Math.round(secs / 60); if (mins < 60) return t("notifications.time.minutes", { count: mins }); const hours = Math.round(mins / 60); if (hours < 24) return t("notifications.time.hours", { count: hours }); const days = Math.round(hours / 24); return t("notifications.time.days", { count: days }); } export default function NotificationsPanel() { const { t } = useTranslation(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], () => api.notifications(), { intervalMs: POLL_MS } ); const items = q.data?.items ?? []; // Any mutation refreshes both the list and the nav badge's unread count. const refresh = () => { qc.invalidateQueries({ queryKey: ["notifications"] }); qc.invalidateQueries({ queryKey: ["notif-unread"] }); }; const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh }); const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh }); const hasUnread = items.some((n) => !n.read); return (
{t("inbox.title")}
{t("inbox.subtitle")}
{items.length > 0 && (
)}
{q.isLoading && !q.data ? (
{t("common.loading")}
) : items.length === 0 ? (
{t("inbox.empty")}
) : (
{items.map((n) => ( readMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)} /> ))}
)}
); } function NotificationRow({ n, t, onRead, onDismiss, }: { n: AppNotification; t: (k: string, o?: any) => string; onRead: () => void; onDismiss: () => void; }) { // The maintenance producer ships the affected videos in `data.videos`; list a few titles. const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) ? n.data!.videos : []; // Known notification types are rendered from i18n (so they're trilingual) using the typed // payload; the server-stored title/body are an English fallback for any unknown type. const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; const title = isMaintenance ? t("inbox.maintenance.title") : n.title; const body = isMaintenance ? t("inbox.maintenance.body", { count: n.data!.count }) : n.body; return (
{!n.read && }
{title} {relativeTime(n.created_at, t)}
{body &&
{body}
} {videos.length > 0 && (
    {videos.slice(0, 8).map((v) => (
  • {v.title || v.id}
  • ))} {videos.length > 8 && (
  • {t("inbox.andMore", { count: videos.length - 8 })}
  • )}
)}
{!n.read && ( )}
); }