Files
siftlode/frontend/src/components/Playlists.tsx
T
peter ad86d6d126 refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.

S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.

Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.

Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
2026-07-27 21:48:35 +02:00

813 lines
33 KiB
TypeScript

import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
Check,
ExternalLink,
GripVertical,
ListPlus,
Pencil,
Play,
RefreshCw,
RotateCcw,
Trash2,
Upload,
Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
ColumnHead,
alignClass,
filterRows,
isActive,
type Column,
type FilterMap,
} from "./tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
//
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
// which still goes through the undoable path — the capability is intact, it just stopped being a
// side effect of looking at the list.
//
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
// its own filter/sort/page state and the stored order needs a single owner.
// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
const idsKey = (items: Video[]) =>
items
.map((v) => v.id)
.sort()
.join(",");
function Row({
video,
columns,
dragDisabled,
dragHint,
}: {
video: Video;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: video.id,
disabled: dragDisabled,
});
return (
<tr
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
transition: isDragging ? undefined : transition,
opacity: isDragging ? 0.6 : 1,
}}
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
>
{col.key === GRIP_KEY ? (
// The one cell the column defs can't render: the handle needs this row's sortable
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing.
<button
{...attributes}
{...(dragDisabled ? {} : listeners)}
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
aria-disabled={dragDisabled || undefined}
onClick={(e) => dragDisabled && e.preventDefault()}
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
aria-label={
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
}
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="w-4 h-4" />
</button>
) : (
col.render(video)
)}
</td>
))}
</tr>
);
}
export default function Playlists() {
const me = useMe();
const canWrite = me.can_write;
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
const order = useUndoable<Video[]>([], {
onApply: (next) => {
if (selectedId == null) return;
api
.reorderPlaylist(
selectedId,
next.map((v) => v.id),
)
.then(() => {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
})
.catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so and resync from the server.
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
// Clear the membership guard first: a reorder never changes the id SET, so without this
// the arriving detail is judged "same list" and the rejected order stays on screen — and
// becomes the base for the next drag.
lastSetRef.current = "";
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
});
},
});
const items = order.value;
// VIEW state — never written to the server on its own.
const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
const [filters, setFilters] = useState<FilterMap>({});
const [groupBy, setGroupBy] = useState(false);
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
// the actual set of items changes (different playlist, or an add/remove).
const lastSetRef = useRef<string>("");
// The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
// a change of view would otherwise slide a different video under the index — or unmount the player
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
const [playingId, setPlayingId] = useState<string | null>(null);
const listQuery = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? [];
const detailQuery = useQuery({
queryKey: qk.playlist(selectedId),
queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
// shows up — including the player's live N / M count, which reads the queue length.
refetchOnWindowFocus: true,
});
const detail = detailQuery.data;
useEffect(() => {
if (!detail) return;
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
// the guard, so the second would render in the first's order and inherit its undo stack.
const key = `${selectedId}:${idsKey(detail.items)}`;
if (key !== lastSetRef.current) {
lastSetRef.current = key;
order.reset(detail.items);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail]);
// Reset the view when switching playlists (the stored order itself is per-playlist).
useEffect(() => {
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}, [selectedId]);
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id;
// LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
// every account without the scope, which they have always had.
const canEdit = editable;
const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
const onRemove = useCallback((v: Video) => {
removeRef.current(v);
}, []);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
return (v: Video) => m.get(v.id) ?? 0;
}, [items]);
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit],
);
const filterActive = Object.values(filters).some(isActive);
const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
[items, columns, filters, viewSort, groupBy],
);
// Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext.
const shownRef = useRef(shown);
shownRef.current = shown;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
function toggleSort(key: string) {
// Cycling off any column lands back on the stored order rather than on a nameless "unsorted".
setViewSort((s) => cycleSort(s, key) ?? STORED_SORT);
}
function applyFilter(key: string, value: string | string[]) {
setFilters((f) => ({ ...f, [key]: value }));
}
function clearView() {
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}
function saveOrder() {
order.set(shown); // onApply persists it
clearView();
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// The grip was focusable and called itself "reorder", but only a pointer could move it.
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? "";
const total = () => shownRef.current.length;
const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
const announcements = {
onDragStart: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndPickedUp", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragOver: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndMovedOver", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragEnd: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndDropped", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragCancel: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
};
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}
async function revertToYoutube() {
if (selectedId == null || !detail || reverting) return;
const ok = await confirm({
title: t("playlists.revertTitle"),
message: t("playlists.revertMsg"),
confirmLabel: t("playlists.revertConfirm"),
danger: true,
});
if (!ok) return;
setReverting(true);
try {
await api.revertPlaylist(selectedId);
refreshAll();
notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
} catch (e) {
notifyYouTubeActionError(e, t("playlists.revertFailed"));
} finally {
setReverting(false);
}
}
async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return;
setPushing(true);
try {
const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
return;
}
if (!plan.affordable) {
notify({
level: "warning",
message: t("playlists.pushNoQuota", {
left: plan.remaining_today,
units: plan.units_estimate,
}),
});
return;
}
let message =
plan.action === "create"
? t("playlists.pushPlanCreate", {
count: plan.to_insert,
units: plan.units_estimate,
left: plan.remaining_today,
})
: t("playlists.pushPlanUpdate", {
insert: plan.to_insert,
del: plan.to_delete,
reorder: plan.to_reorder,
units: plan.units_estimate,
left: plan.remaining_today,
});
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
const ok = await confirm({
title: t("playlists.pushTitle"),
message,
confirmLabel: t("playlists.pushConfirm"),
});
if (!ok) return;
const r = await api.pushPlaylist(selectedId);
refreshAll();
if (r.failures.length) {
notify({
level: "warning",
message: t("playlists.pushPartial", { count: r.failures.length }),
});
} else {
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
}
} catch (e) {
notifyYouTubeActionError(e, t("playlists.pushFailed"));
} finally {
setPushing(false);
}
}
function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e;
if (!over || a.id === over.id || selectedId == null) return;
const oldIndex = items.findIndex((v) => v.id === a.id);
const newIndex = items.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
}
async function removeItem(video: Video) {
if (selectedId == null) return;
const ok = await confirm({
title: t("playlists.removeItem"),
message: t("playlists.confirmRemove", { title: video.title }),
confirmLabel: t("playlists.removeItem"),
danger: true,
});
if (!ok) return;
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
// drops the item from every snapshot, so the stack stays valid for what's left.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
try {
await api.removeFromPlaylist(selectedId, video.id);
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();
}
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: qk.feed() });
});
}
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlayingId(null), []);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel =
viewSort && !isStoredSort(viewSort)
? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
viewSort.dir === "asc" ? "↑" : "↓"
}`
: null;
const viewName = groupBy
? sortLabel
? t("playlists.viewGroupedAnd", { sort: sortLabel })
: t("playlists.viewGrouped")
: (sortLabel ?? "");
const viewStatus = filterActive
? t("playlists.viewFiltered", { shown: shown.length, total: items.length })
: canSaveOrder
? t("playlists.viewDiffers", { view: viewName })
: t("playlists.viewMatches", { view: viewName });
// Order matters: a built-in list is never reorderable, whatever the view says.
const dragHint = !canEdit
? t("playlists.dragBlockedBuiltin")
: filterActive
? t("playlists.dragBlockedFilter")
: t("playlists.dragBlockedSort");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
// The page used to render "Loading…" forever when the detail query failed — an error read as a
// slow network that never arrived.
if (detailQuery.isError) {
return (
<div className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-red-400" />
{t("playlists.loadFailed")}
</span>
<button
onClick={() => detailQuery.refetch()}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
{t("common.retry")}
</button>
</div>
);
}
if (!detail) {
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
}
return (
<div className="px-4 pb-4 pt-3">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
</div>
</div>
</div>
{(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
link + save hint + "reordering is off"), and none of them answered the question a
user actually has — "did I just change my playlist?". Worse, sorting a list whose
stored order ALREADY matches the sort produced a warning, no Save button and no
visible change, which reads as a malfunction. Now the line says which of the three
situations you are in; the disabled grip still explains itself on hover/focus. */}
{viewActive && <span className="text-[11px] text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
// drag; now that the keyboard can, they ARE the instructions for the feature.
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl">
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
<table className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
<Row
key={v.id}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
/>
))}
</tbody>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</div>
</DndContext>
)}
{playingIndex >= 0 && (
<Suspense fallback={null}>
<PlayerModal
video={shown[playingIndex]!}
queue={shown}
startIndex={playingIndex}
startAt={null}
onClose={closePlayer}
onState={playerState}
/>
</Suspense>
)}
</div>
);
}