`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a 502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden channel that snapped back, a share link that never revoked, a confirmed account delete that ended with nothing said. - lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own onError, so it waits a task and skips a failure the site already reported; the toast title comes from `meta.failTitle`, `meta.errorShownInline` opts out. - notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a structured localized detail, else the status). It marks the error reported (`markReported`), so nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence. - Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it" comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue, account delete and usePref. - Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks now report themselves, with the real error. - A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row. - history.dom.test: the "navigates only once the entries are back" assertion raced an async act's macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
403 lines
17 KiB
TypeScript
403 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
|
|
import { api, type Playlist } from "../lib/api";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notify } from "../lib/notifications";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
|
import { type PanelLayout } from "../lib/panelLayout";
|
|
import FilterRail from "./FilterRail";
|
|
import { Button } from "./ui/Button";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { useComfortPin, pinPlaylist } from "./comfortPin";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { type PanelItem } from "./ui/PanelGroups";
|
|
import SortControl from "./ui/SortControl";
|
|
import { type SortOption } from "../lib/columnSort";
|
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
|
import { useMe } from "../lib/useMe";
|
|
import { playlistName } from "../lib/playlistName";
|
|
import { usePlaylistActions } from "./usePlaylistActions";
|
|
|
|
type PlSort = {
|
|
key: "custom" | "name" | "count" | "duration" | "recent";
|
|
dir: "asc" | "desc";
|
|
dirtyFirst: boolean;
|
|
};
|
|
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
|
|
|
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
|
|
// becomes) — the row must not resize the moment you rename it.
|
|
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
|
|
|
|
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
|
|
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
|
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
|
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
|
export default function PlaylistsRail({
|
|
layout,
|
|
setLayout,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
}: {
|
|
layout: PanelLayout;
|
|
setLayout: (l: PanelLayout) => void;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
}) {
|
|
const canWrite = useMe().can_write;
|
|
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
|
|
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
|
const { search, selectedId } = usePlaylists();
|
|
const { setSelectedId } = usePlaylistsActions();
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
|
const plSortOptions: SortOption[] = [
|
|
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
|
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
|
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
|
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
|
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
|
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
|
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
|
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
|
];
|
|
useEffect(() => {
|
|
writeAccount(LS.plSort, plSort);
|
|
}, [plSort]);
|
|
const [newName, setNewName] = useState("");
|
|
const [syncing, setSyncing] = useState(false);
|
|
const plActions = usePlaylistActions();
|
|
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
|
const railMenu = useContextMenu<Playlist>();
|
|
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
|
|
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
|
|
const comfortPin = useComfortPin(railMenu.open);
|
|
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
|
// context menu is that the action lands where the gesture did.
|
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
|
const scrolledRef = useRef(false);
|
|
|
|
const listQuery = useQuery({
|
|
queryKey: qk.playlists(),
|
|
queryFn: () => api.playlists(),
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
const playlists = listQuery.data ?? [];
|
|
|
|
// Keep the selection valid: default to the first playlist, and if the selected one disappears
|
|
// drop to the next available (or clear when none remain). Waits for the first load.
|
|
useEffect(() => {
|
|
if (!listQuery.data) return;
|
|
if (!playlists.length) {
|
|
if (selectedId != null) setSelectedId(null);
|
|
return;
|
|
}
|
|
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
|
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [playlists, selectedId, listQuery.data]);
|
|
|
|
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
|
|
|
const sortedPlaylists = useMemo(() => {
|
|
const sign = plSort.dir === "asc" ? 1 : -1;
|
|
return [...playlists].sort((a, b) => {
|
|
if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1;
|
|
switch (plSort.key) {
|
|
case "name":
|
|
return sign * plName(a).localeCompare(plName(b));
|
|
case "count":
|
|
return sign * (a.item_count - b.item_count);
|
|
case "duration":
|
|
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
|
case "recent":
|
|
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [playlists, plSort]);
|
|
|
|
const q = search.trim().toLowerCase();
|
|
const visiblePlaylists = useMemo(
|
|
() =>
|
|
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[sortedPlaylists, q],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!scrolledRef.current && selectedRef.current) {
|
|
selectedRef.current.scrollIntoView({ block: "nearest" });
|
|
scrolledRef.current = true;
|
|
}
|
|
}, [sortedPlaylists]);
|
|
|
|
async function syncYoutube() {
|
|
if (syncing) return;
|
|
setSyncing(true);
|
|
try {
|
|
const r = await api.syncYoutubePlaylists();
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
|
|
} catch (e) {
|
|
notifyYouTubeActionError(e, t("playlists.syncFailed"));
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (name: string) => api.createPlaylist(name),
|
|
meta: { failTitle: "errors.writeFailed.createPlaylist" },
|
|
onSuccess: (pl: Playlist) => {
|
|
setNewName("");
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
setSelectedId(pl.id);
|
|
},
|
|
});
|
|
|
|
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
|
|
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
|
|
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
|
|
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
|
|
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
|
|
// blur-commit means the user put the focus somewhere else on purpose.
|
|
const refocusRowRef = useRef<number | null>(null);
|
|
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
|
|
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
|
|
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
|
|
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
|
|
// focused and survives.
|
|
useEffect(() => {
|
|
if (renamingId !== null) renameInputRef.current?.focus();
|
|
}, [renamingId]);
|
|
|
|
async function commitRename(pl: Playlist) {
|
|
const name = renameValue.trim();
|
|
setRenamingId(null);
|
|
if (name && name !== pl.name) await plActions.rename(pl.id, name);
|
|
}
|
|
|
|
const ctxPl = railMenu.target;
|
|
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
|
|
// stay visible and disabled so the menu doesn't change shape from list to list.
|
|
const ctxBuiltin = ctxPl?.kind === "watch_later";
|
|
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
|
|
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
|
|
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
|
|
const lockedTitle = ctxBuiltin
|
|
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
|
|
: undefined;
|
|
const railItems: ActionMenuItem[] = !ctxPl
|
|
? []
|
|
: [
|
|
{
|
|
id: "rename",
|
|
label: t("playlists.rename"),
|
|
icon: <Pencil className="w-4 h-4" />,
|
|
disabled: ctxBuiltin,
|
|
title: lockedTitle,
|
|
onSelect: () => {
|
|
setRenameValue(ctxPl.name);
|
|
setRenamingId(ctxPl.id);
|
|
},
|
|
},
|
|
...comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
|
|
{
|
|
id: "delete",
|
|
label: t("playlists.delete"),
|
|
icon: <Trash2 className="w-4 h-4" />,
|
|
separatorBefore: true,
|
|
danger: true,
|
|
disabled: ctxBuiltin,
|
|
title: lockedTitle,
|
|
// The same dialog (and the same "what gets selected next" rule) as the detail header's
|
|
// trash button — one implementation, two entry points.
|
|
onSelect: () => void plActions.remove(ctxPl),
|
|
},
|
|
];
|
|
|
|
const items: PanelItem[] = [
|
|
{
|
|
id: "manage",
|
|
title: t("playlists.manageGroup"),
|
|
body: (
|
|
<div className="flex flex-col gap-2">
|
|
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
|
{t("playlists.syncYoutube")}
|
|
</Button>
|
|
{playlists.length > 1 && (
|
|
<SortControl
|
|
label={t("playlists.sortLabel")}
|
|
ascLabel={t("playlists.dirAsc")}
|
|
descLabel={t("playlists.dirDesc")}
|
|
options={plSortOptions}
|
|
sort={{ key: plSort.key, dir: plSort.dir }}
|
|
onChange={(next) =>
|
|
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
|
}
|
|
trailing={
|
|
<button
|
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
|
title={t("playlists.dirtyFirst")}
|
|
aria-pressed={plSort.dirtyFirst}
|
|
className="pill shrink-0 rounded-lg border p-1.5"
|
|
>
|
|
<Pin className="h-4 w-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
)}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newName.trim()) createMut.mutate(newName.trim());
|
|
}}
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-4 h-4 text-muted shrink-0" />
|
|
<input
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder={t("playlists.newPlaylist")}
|
|
disabled={!canWrite}
|
|
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
|
/>
|
|
</form>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "list",
|
|
title: t("playlists.listGroup"),
|
|
body: (
|
|
<>
|
|
{playlists.length === 0 &&
|
|
(listQuery.isError && !listQuery.data ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => listQuery.refetch()}
|
|
className="text-xs text-danger px-1 py-1 text-left hover:underline"
|
|
>
|
|
{t("common.loadError")} — {t("common.retry")}
|
|
</button>
|
|
) : listQuery.isLoading ? (
|
|
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
|
|
) : (
|
|
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
|
|
))}
|
|
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
|
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
{visiblePlaylists.map((pl) =>
|
|
renamingId === pl.id ? (
|
|
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
|
// whole row swaps rather than the label alone — but it KEEPS the cover, because
|
|
// that is what holds the row at its two-line height. Without it the rail jumps by
|
|
// ~12px the moment you start renaming, and every row below it moves under the
|
|
// pointer.
|
|
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
|
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
|
{pl.cover_thumbnail && (
|
|
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={() => void commitRename(pl)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
refocusRowRef.current = pl.id;
|
|
void commitRename(pl);
|
|
}
|
|
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
|
if (e.key === "Escape") {
|
|
refocusRowRef.current = pl.id;
|
|
setRenamingId(null);
|
|
}
|
|
}}
|
|
aria-label={t("playlists.rename")}
|
|
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
key={pl.id}
|
|
ref={(el) => {
|
|
if (pl.id === selectedId) selectedRef.current = el;
|
|
// Back from the rename field by Enter/Escape — see refocusRowRef.
|
|
if (el && refocusRowRef.current === pl.id) {
|
|
refocusRowRef.current = null;
|
|
el.focus();
|
|
}
|
|
}}
|
|
onClick={() => setSelectedId(pl.id)}
|
|
{...railMenu.triggerProps(pl)}
|
|
className={`${ROW_BASE} text-left ${
|
|
pl.id === selectedId
|
|
? "bg-accent/15 border-accent"
|
|
: "border-transparent hover:bg-card/60"
|
|
}`}
|
|
>
|
|
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
|
{pl.cover_thumbnail && (
|
|
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
|
<span className="truncate">{plName(pl)}</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<Youtube
|
|
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="text-2xs text-muted">
|
|
{t("playlists.itemCount", { count: pl.item_count })}
|
|
{pl.total_duration_seconds > 0 &&
|
|
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
|
</>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<FilterRail
|
|
kind="playlists"
|
|
title={t("playlists.title")}
|
|
icon={<ListVideo className="w-4 h-4" />}
|
|
collapsed={collapsed}
|
|
onToggleCollapse={onToggleCollapse}
|
|
layout={layout}
|
|
setLayout={setLayout}
|
|
items={items}
|
|
/>
|
|
);
|
|
}
|