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.
813 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|