Files
siftlode/frontend/src/components/NotificationsPanel.tsx
T
peter ec3bbc78dc style(layout): tighten the oversized vertical gaps around the page toolbars
The floating-header → toolbar → filter-chips → content stack carried ~2× the whitespace it
needed. Halve it, app-wide where the chrome is shared:
- PageShell: the --hdr-h gap below the floating header 8px → 4px (every page's header).
- Feed: toolbar wrapper pt-4 → pt-2, the toolbar's own pb-3 → pb-1.5, and the grid's top pt-2
  → pt-1. Measured on the feed: header→toolbar 24→12px, toolbar→chips 12→6px, chips→cards 12→8px.
- Plex / Playlists / ChannelDiscovery / DownloadCenter / Notifications / AdminUsers: toolbar
  wrapper pt-3 → pt-2, so every page's top band matches the tighter feed.

PageShell.dom.test updated for the 4px gap.
2026-08-21 19:13:38 +02:00

472 lines
18 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 } 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, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// 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;
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 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 });
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 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();
}
function clearEverything() {
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.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
...filters,
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")));
}
return (
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
onClick={clearEverything}
disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</div>
)}
</div>
</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 />
) : !hasAny ? (
<div className="glass rounded-2xl 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>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
t={t}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
onOpenDownloads={() => setPage("downloads")}
/>
))}
</div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locate}
onRevert={revertState}
onFocusChannel={onFocusChannel}
onReviewAccess={() => {
focusAccessRequestsTab();
setPage("users");
}}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
</>
);
}
function NotificationRow({
n,
t,
onRead,
onDismiss,
onOpenScheduler,
onOpenDownloads,
}: {
n: AppNotification;
t: TFunction;
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 = n.type === "scheduler" && typeof n.data?.job_id === "string";
const isDownloadDone = n.type === "download_done";
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 (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
className={`glass-card rounded-xl 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>
<span className="text-2xs text-muted shrink-0">{relativeTime(n.created_at)}</span>
</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
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
)}
{isDownloadDone && (
<button
onClick={onOpenDownloads}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Download className="w-3.5 h-3.5" />
{t("inbox.openDownloads")}
</button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<button
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
)}
<button
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
function ClientActivityRow({
n,
t,
onFind,
onRevert,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void;
onReviewAccess: () => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
className={`glass-card rounded-xl 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">
<span className="text-2xs text-muted">{relativeFromMs(n.ts)}</span>
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFocusChannel(subscribed.channelName)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<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"
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : access ? (
<button
onClick={() => onReviewAccess()}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
>
<UserPlus className="w-3.5 h-3.5" />
{t("common.accessRequestsReview")}
</button>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
<button
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}