Files
siftlode/frontend/src/components/NotificationsPanel.tsx
T
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00

699 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
Activity,
Bell,
Check,
CheckCheck,
Download,
ExternalLink,
Eye,
RotateCcw,
Search,
Trash2,
Tv,
UserPlus,
X,
} from "lucide-react";
import { api, type AppNotification } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import {
clearAll as clearClient,
dismiss as dismissClient,
getNotifications,
getUnreadCount,
markAllRead as markAllClientRead,
remove as removeClient,
resolveVideo,
subscribe,
type Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./navigationContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { useContextMenu, type ContextMenuController } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { HOVER_REVEAL } from "./ui/reveal";
import { useConfirm } from "./confirmContext";
import { RelativeTime } from "./ui/RelativeTime";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
// their inline undo/find actions) that used to live behind a separate bell — folded in here so
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
qk.notifications(),
() => api.notifications(),
{ intervalMs: POLL_MS },
);
const serverItems = q.data?.items ?? [];
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
// items stay unread until acted on individually.
useEffect(() => {
markAllClientRead();
}, []);
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({
mutationFn: api.markNotificationRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifRead" },
});
const readAllMut = useMutation({
mutationFn: api.markAllNotificationsRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifReadAll" },
});
const dismissMut = useMutation({
mutationFn: api.dismissNotification,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifDismiss" },
});
const clearMut = useMutation({
mutationFn: api.clearNotifications,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifClear" },
});
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
const hasUnread = serverUnread || clientUnread > 0;
function markAll() {
markAllClientRead();
if (serverUnread) readAllMut.mutate();
else refresh();
}
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
// band into the header's "…" (E28 C1), where a destructive row keeps its confirm.
async function clearEverything() {
const ok = await confirm({
title: t("inbox.clearConfirmTitle"),
message: t("inbox.clearConfirmBody"),
confirmLabel: t("inbox.clearAll"),
danger: true,
});
if (!ok) return;
clearClient();
clearMut.mutate();
}
// "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
// notices — jump to the feed filtered to that state and the video's channel.
// The narrowing filters that could leave the video out (a tag, "no tag", a search, an age cap) are
// dropped: kept, a channel outside the reader's tag filter found an empty feed.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
...filters,
tags: [],
untagged: false,
q: "",
maxAgeDays: undefined,
show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelIds: meta.channelId ? [meta.channelId] : [],
});
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can
// do — the link again, find / undo on an activity row, mark read, dismiss — is its menu.
// One menu per list, keyed by the notification's id: the server list polls, and the "…" must still
// close the menu a poll replaced its row under.
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openDownloads = () => setPage("downloads");
const reviewAccess = () => {
focusAccessRequestsTab();
setPage("users");
};
const icon = (I: typeof Check) => <I className="w-4 h-4" />;
const dismissRow = (onSelect: () => void): ActionMenuItem => ({
id: "dismiss",
label: t("inbox.dismiss"),
icon: icon(X),
onSelect,
});
function serverRows(n: AppNotification): ActionMenuItem[] {
const kind = serverKind(n);
return menuGroups(
[
...(kind === "scheduler"
? [
{
id: "open",
label: t("inbox.openScheduler"),
icon: icon(Activity),
onSelect: openScheduler,
},
]
: kind === "download"
? [
{
id: "open",
label: t("inbox.openDownloads"),
icon: icon(Download),
onSelect: openDownloads,
},
]
: []),
...(!n.read
? [
{
id: "read",
label: t("inbox.markRead"),
icon: icon(Check),
onSelect: () => readMut.mutate(n.id),
},
]
: []),
],
[dismissRow(() => dismissMut.mutate(n.id))],
);
}
function clientRows(n: ClientNotif): ActionMenuItem[] {
const m = n.meta;
const own: ActionMenuItem[] =
m?.kind === "video-hidden" || m?.kind === "video-watched"
? [
{
id: "find",
label: t("notifications.findInFeed"),
icon: icon(Search),
onSelect: () => locate(m),
},
{
id: "revert",
label: t(
m.kind === "video-hidden" ? "notifications.unhide" : "notifications.unwatch",
),
icon: icon(m.kind === "video-hidden" ? Eye : RotateCcw),
onSelect: () => revertState(m),
},
]
: m?.kind === "channel-subscribed"
? [
{
id: "manager",
label: t("notifications.openInManager"),
icon: icon(Tv),
onSelect: () => onFocusChannel(m.channelName),
},
{
id: "youtube",
label: t("notifications.openOnYouTube"),
icon: icon(ExternalLink),
// A new tab only opens inside the click (a popup blocker refuses one a task later).
inGesture: true,
onSelect: () =>
void window.open(channelYouTubeUrl(m.channelId), "_blank", "noopener,noreferrer"),
},
]
: m?.kind === "access-requests"
? [
{
id: "review",
label: t("common.accessRequestsReview"),
icon: icon(UserPlus),
onSelect: reviewAccess,
},
]
: n.action && !n.dismissed
? [
{
id: "action",
label: n.action.label,
onSelect: () => {
n.action!.onClick();
dismissClient(n.id);
},
},
]
: [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
}
const more = <T extends { id: number }>(menu: ContextMenuController<T>, n: T) => {
const mine = menu.open && menu.target?.id === n.id;
return (
<MoreButton
label={t("inbox.rowMenu")}
expanded={mine}
menuProps={menu.buttonProps(n)}
className={mine ? undefined : "reveal"}
/>
);
};
// --- The page's own menu: the header's "…" ------------------------------------------------------
const headerMenu = useContextMenu<"inbox">();
const headerItems: ActionMenuItem[] = [
{
id: "read-all",
label: t("inbox.markAllRead"),
icon: icon(CheckCheck),
disabled: !hasUnread || readAllMut.isPending,
onSelect: markAll,
},
{
id: "clear",
label: t("inbox.clearAll"),
icon: icon(Trash2),
danger: true,
separatorBefore: true,
disabled: clearMut.isPending,
onSelect: () => void clearEverything(),
},
];
return (
<>
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
shell's hidden h1 names it to a screen reader. */}
{/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive
action — Clear everything stays behind its "…" and its confirm. */}
{hasAny && (
<PageMenu
ariaLabel={t("common.pageMenu")}
items={headerItems.filter((it) => !("danger" in it && it.danger))}
/>
)}
{hasAny && (
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
icon={<CheckCheck />}
>
{t("inbox.markAllRead")}
</Button>
<MoreButton
label={t("inbox.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps("inbox")}
/>
</div>
</PageToolbar>
)}
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isError && !hasAny ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState shape="list" />
) : !hasAny ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{serverItems.map((n, i) => {
return (
<NotificationRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(n)}
more={more(serverMenu, n)}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenDownloads={openDownloads}
/>
);
})}
</NavGrid>
</section>
)}
{clientItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{clientItems.map((n, i) => {
return (
<ClientActivityRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(n)}
more={more(clientMenu, n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => removeClient(n.id)}
/>
);
})}
</NavGrid>
</section>
)}
</div>
)}
</div>
<ContextMenu
menu={serverMenu}
ariaLabel={t("inbox.rowMenu")}
items={serverMenu.target ? serverRows(serverMenu.target) : []}
/>
<ContextMenu
menu={clientMenu}
ariaLabel={t("inbox.rowMenu")}
items={clientMenu.target ? clientRows(clientMenu.target) : []}
/>
<ContextMenu menu={headerMenu} ariaLabel={t("inbox.menu")} items={headerItems} />
</>
);
}
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
function serverKind(n: AppNotification): "scheduler" | "download" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
return null;
}
/** A row's end: its unread mark (server rows), its dismiss on hover (mouse; on touch it is in the
* "…"), then its "…". */
function RowEnd({
onRead,
onDismiss,
more,
}: {
onRead?: () => void;
onDismiss: () => void;
more: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1 shrink-0">
{onRead && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<IconButton
label={t("inbox.dismiss")}
size="sm"
variant="ghost"
onClick={onDismiss}
className={HOVER_REVEAL}
>
<X className="w-4 h-4" />
</IconButton>
{more}
</div>
);
}
function NotificationRow({
n,
t,
navIndex,
rowProps,
more,
onRead,
onDismiss,
onOpenScheduler,
onOpenDownloads,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenDownloads: () => 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 isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
let title = n.title;
let body = n.body;
if (isMaintenance) {
// Guarded above (typeof count === "number"); shape the JSON `data` bag for typed access.
const d = n.data as { count: number };
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const d = n.data as { job_id: string; status?: string; summary?: string };
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job,
});
body = d.summary || n.body;
} else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get.
const d = n.data as { reason: string; subject?: string };
title = t("inbox.emailUndelivered.title");
body = t(
d.reason === "not_configured"
? "inbox.emailUndelivered.notConfigured"
: "inbox.emailUndelivered.sendFailed",
{ subject: d.subject || n.body || "" },
);
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
const d = n.data as { title?: string | null; kind?: string };
title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title");
body = d.title || n.body;
}
return (
<div
{...navItemAttrs(navIndex, title)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
{!n.read && <span className="mt-1.5 w-2 h-2 rounded-full bg-accent shrink-0" aria-hidden />}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<RelativeTime at={n.created_at} className="text-2xs text-muted shrink-0" />
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
<ul className="mt-2 text-xs text-muted list-disc pl-4 space-y-0.5">
{videos.slice(0, 8).map((v) => (
<li key={v.id} className="truncate">
{v.title || v.id}
</li>
))}
{videos.length > 8 && (
<li className="list-none text-muted/70">
{t("inbox.andMore", { count: videos.length - 8 })}
</li>
)}
</ul>
)}
{isScheduler && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenScheduler}
icon={<Activity className="w-3.5 h-3.5" />}
>
{t("inbox.openScheduler")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenDownloads}
icon={<Download className="w-3.5 h-3.5" />}
>
{t("inbox.openDownloads")}
</Button>
)}
</div>
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
</div>
);
}
function ClientActivityRow({
n,
t,
navIndex,
rowProps,
more,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onFocusChannel: (name: string) => void;
onReviewAccess: () => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads.
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
{...navItemAttrs(navIndex, n.title || n.message)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm wrap-break-word">
{n.message}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<RelativeTime at={n.ts} className="text-2xs text-muted" />
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{quiet ? null : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
data-nav-open=""
onClick={() => onFocusChannel(subscribed.channelName)}
icon={<Tv className="w-3.5 h-3.5" />}
>
{t("notifications.openInManager")}
</Button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
// The link keeps the browser's own right-click menu; the row's answers elsewhere.
onContextMenu={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : access ? (
<Button
variant="link"
className="text-sm mt-1"
data-nav-open=""
onClick={() => onReviewAccess()}
icon={<UserPlus className="w-3.5 h-3.5" />}
>
{t("common.accessRequestsReview")}
</Button>
) : (
n.action &&
!n.dismissed && (
<Button
variant="link"
className="mt-1 text-sm"
data-nav-open=""
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
>
{n.action.label}
</Button>
)
)}
</div>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}