feat(ux): R11 feedback symmetry — Plex/download/rollback/deny notices

Give equal-weight actions equal-weight feedback (R11).

S1 Plex error toasts: the watched-toggles (card / season mark-all / episode /
info-panel / show mark-all) used to fail silently on 403/404/429; each now surfaces
the server reason via a shared notifyActionError() + resyncs the optimistic flip.
The playback auto-mark-watched stays intentionally silent (a mid-play toast is
noise; the next sync corrects it). New generic lib/actionErrors.ts (Plex + saved
views reuse it; notifyYouTubeActionError delegates its non-403 case to it).

S2 download-done inbox notification: worker.py emits a best-effort type=download_done
notice to the job owner on real completion (regular + edit jobs, not instant
cache-hits), rendered/localized in the inbox with an Open-Downloads link.

S3 optimistic rollbacks: SavedViewsWidget create/rename/toggle-default/remove/reorder
and the inbox revert-state no longer swallow failures — they toast + refetch to
resync (the reorder's optimistic order reverts to server truth).

S4 access-request deny: deny_invite now emails the requester (send_access_denied),
mirroring approve — no more silent limbo.
This commit is contained in:
2026-08-11 05:05:50 +02:00
parent 354b3dd10c
commit e2fe6fa286
15 changed files with 154 additions and 14 deletions
+21 -1
View File
@@ -8,6 +8,7 @@ import {
Bell,
Check,
CheckCheck,
Download,
ExternalLink,
Eye,
RotateCcw,
@@ -18,6 +19,7 @@ import {
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 {
@@ -114,7 +116,7 @@ export default function NotificationsPanel() {
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch(() => {});
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
}
return (
@@ -180,6 +182,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
onOpenDownloads={() => setPage("downloads")}
/>
))}
</div>
@@ -222,12 +225,14 @@ function NotificationRow({
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)
@@ -237,6 +242,7 @@ function NotificationRow({
// 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) {
@@ -252,6 +258,11 @@ function NotificationRow({
job,
});
body = d.summary || n.body;
} else if (isDownloadDone) {
// "Download ready", with the video's title as the body (the producer ships it in data.title).
const d = n.data as { title?: string | null };
title = t("inbox.downloadDone.title");
body = d.title || n.body;
}
return (
<div
@@ -289,6 +300,15 @@ function NotificationRow({
{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 && (