fix(playlists-ui): surface YouTube 403s + rollback remove; drop dup name/import

BUGS:
- push/sync/revert (PF1/PF2/PF4) swallowed every failure into a generic warning,
  so a missing-write-scope 403 gave no hint and no "Connect your YouTube account"
  affordance (unlike Channels). Route all three through notifyYouTubeActionError
  (no wizard handle on this screen, so message-only for the 403).
- removeItem (PF5) reset the order optimistically then awaited the delete with no
  catch: a failed call left the row gone locally (and an unhandled rejection).
  Now resync via refreshAll on either path (api.req surfaces the error dialog).

CLEANUP:
- Drop the dead `Check` lucide import (PC6; only AddToPlaylist uses it).
- Use the module plName(detail) helper instead of the inline watch_later-vs-name
  ternary that shadowed it in push/revert (PC7).

tsc green, localdev boots healthy.
This commit is contained in:
npeter83 2026-07-11 18:33:16 +02:00
parent ecb2e0b749
commit c0e941bc0c

View file

@ -19,7 +19,6 @@ import { CSS } from "@dnd-kit/utilities";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check,
ExternalLink, ExternalLink,
GripVertical, GripVertical,
ListPlus, ListPlus,
@ -37,6 +36,7 @@ import {
import { api, type Playlist, type Video } from "../lib/api"; import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
@ -347,14 +347,13 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setReverting(true); setReverting(true);
try { try {
await api.revertPlaylist(selectedId); await api.revertPlaylist(selectedId);
refreshAll(); refreshAll();
notify({ level: "success", message: t("playlists.revertDone", { name: plName }) }); notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.revertFailed") }); notifyYouTubeActionError(e, t("playlists.revertFailed"));
} finally { } finally {
setReverting(false); setReverting(false);
} }
@ -362,12 +361,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
async function pushToYoutube() { async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return; if (selectedId == null || !detail || pushing) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setPushing(true); setPushing(true);
try { try {
const plan = await api.playlistPushPlan(selectedId); const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) { if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName }) }); notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
return; return;
} }
if (!plan.affordable) { if (!plan.affordable) {
@ -406,10 +404,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
if (r.failures.length) { if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
} else { } else {
notify({ level: "success", message: t("playlists.pushDone", { name: plName }) }); notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
} }
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.pushFailed") }); notifyYouTubeActionError(e, t("playlists.pushFailed"));
} finally { } finally {
setPushing(false); setPushing(false);
} }
@ -423,8 +421,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: ["playlist"] });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.syncFailed") }); notifyYouTubeActionError(e, t("playlists.syncFailed"));
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
@ -463,8 +461,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const next = items.filter((v) => v.id !== videoId); const next = items.filter((v) => v.id !== videoId);
order.reset(next); order.reset(next);
lastSetRef.current = idsKey(next); lastSetRef.current = idsKey(next);
await api.removeFromPlaylist(selectedId, videoId); try {
refreshAll(); await api.removeFromPlaylist(selectedId, videoId);
refreshAll();
} catch {
// The optimistic removal above didn't stick — resync from the server to restore the row.
// api.req already surfaced the error to the user via the global error dialog.
refreshAll();
}
} }
async function saveRename() { async function saveRename() {