Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab.
497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
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 { Button } from "./ui/Button";
|
||
import { LEVEL_STYLE } from "./ui/Toaster";
|
||
import { PageToolbar } from "./ui/PageShell";
|
||
import { LoadingState, StateMessage } from "./ui/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
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={markAll}
|
||
disabled={!hasUnread || readAllMut.isPending}
|
||
icon={<CheckCheck className="w-4 h-4" />}
|
||
>
|
||
{t("inbox.markAllRead")}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={clearEverything}
|
||
disabled={clearMut.isPending}
|
||
icon={<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";
|
||
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
|
||
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
|
||
variant="link"
|
||
className="text-sm mt-1.5"
|
||
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"
|
||
onClick={onOpenDownloads}
|
||
icon={<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
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onRead}
|
||
title={t("inbox.markRead")}
|
||
aria-label={t("inbox.markRead")}
|
||
icon={<Check className="w-4 h-4" />}
|
||
/>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onDismiss}
|
||
title={t("inbox.dismiss")}
|
||
aria-label={t("inbox.dismiss")}
|
||
icon={<X className="w-4 h-4" />}
|
||
/>
|
||
</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
|
||
variant="link"
|
||
className="text-sm"
|
||
onClick={() => onFind(hidden)}
|
||
icon={<Search className="w-3.5 h-3.5" />}
|
||
>
|
||
{t("notifications.findInFeed")}
|
||
</Button>
|
||
<Button
|
||
variant="link"
|
||
className="text-sm"
|
||
onClick={() => onRevert(hidden)}
|
||
icon={<Eye className="w-3.5 h-3.5" />}
|
||
>
|
||
{t("notifications.unhide")}
|
||
</Button>
|
||
</div>
|
||
) : watched ? (
|
||
<div className="flex items-center gap-3 mt-1">
|
||
<Button
|
||
variant="link"
|
||
className="text-sm"
|
||
onClick={() => onFind(watched)}
|
||
icon={<Search className="w-3.5 h-3.5" />}
|
||
>
|
||
{t("notifications.findInFeed")}
|
||
</Button>
|
||
<Button
|
||
variant="link"
|
||
className="text-sm"
|
||
onClick={() => onRevert(watched)}
|
||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||
>
|
||
{t("notifications.unwatch")}
|
||
</Button>
|
||
</div>
|
||
) : subscribed ? (
|
||
<div className="flex items-center gap-3 mt-1">
|
||
<Button
|
||
variant="link"
|
||
className="text-sm"
|
||
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"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
{t("notifications.openOnYouTube")}
|
||
</a>
|
||
</div>
|
||
) : access ? (
|
||
<Button
|
||
variant="link"
|
||
className="text-sm mt-1"
|
||
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"
|
||
onClick={() => {
|
||
n.action!.onClick();
|
||
dismissClient(n.id);
|
||
}}
|
||
>
|
||
{n.action.label}
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="shrink-0"
|
||
onClick={onRemove}
|
||
title={t("inbox.dismiss")}
|
||
aria-label={t("inbox.dismiss")}
|
||
icon={<X className="w-4 h-4" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|