feat(notifications): durable per-user inbox (P1) + maintenance schema

Add a server-backed notification center that coexists with the client-side
transient bell: a per-user `notifications` table (type/title/body/data JSON/
read/dismissed), a `/api/me/notifications` CRUD API (list, unread_count, read,
read_all, dismiss, clear), and a left-nav inbox module with a live unread badge
polled via useLiveQuery. Known types render trilingual text from type+data
(English stored text is the fallback); read rows are trimmed past a soft cap.

Also adds the schema the maintenance job builds on: videos.list?part=status
columns (embeddable/privacy_status/upload_status) and the validation lifecycle
columns (last_checked_at, unavailable_since, unavailable_reason).

Page-id validation is centralized in one PAGES source of truth (isPage) so the
new page survives reload without a second allowlist to keep in sync.
This commit is contained in:
npeter83 2026-06-18 03:20:17 +02:00
parent a11a8db278
commit b9a3a9012d
14 changed files with 649 additions and 22 deletions

View file

@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import {
Activity,
BarChart3,
Bell,
ChevronLeft,
ChevronRight,
Home,
@ -17,6 +18,7 @@ import {
UserPlus,
} from "lucide-react";
import { api, type FeedFilters, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
@ -116,12 +118,22 @@ export default function NavSidebar({
}
}
type NavItem = { page: Page; icon: typeof Home; label: string };
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(
["notif-unread"],
api.notificationUnreadCount,
{ intervalMs: 30000 }
);
const unread = unreadQuery.data?.count ?? 0;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = [
{ page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
];
const systemItems: NavItem[] =
me.role === "admin"
@ -135,18 +147,29 @@ export default function NavSidebar({
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => (
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
<span className="relative shrink-0">
<Icon className="w-[18px] h-[18px]" />
{/* Collapsed rail: a small dot on the icon; expanded: a numeric pill at the row end. */}
{!!badge && badge > 0 && collapsed && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</span>
{!collapsed && <span className="truncate">{label}</span>}
{!!badge && badge > 0 && !collapsed && (
<span className="ml-auto min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-accent-fg text-[11px] font-semibold inline-flex items-center justify-center tabular-nums">
{badge > 99 ? "99+" : badge}
</span>
)}
</button>
);