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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user