- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper, Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default. - Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does. - Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and the Plex two-way-sync switch had no name. - z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract. - The /watch file card's Download is the filled primary: the page's one action. - Stale comments: the 1024x768 floor, the retired glass tuner.
1108 lines
50 KiB
TypeScript
1108 lines
50 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,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import {
|
||
ArrowDown,
|
||
ArrowDownToLine,
|
||
ArrowUp,
|
||
ArrowUpToLine,
|
||
Check,
|
||
ExternalLink,
|
||
FolderInput,
|
||
History,
|
||
ListPlus,
|
||
Pencil,
|
||
Play,
|
||
RotateCcw,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
Youtube,
|
||
} from "lucide-react";
|
||
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
|
||
import { notify } from "../lib/notifications";
|
||
import { notifyActionError } from "../lib/actionErrors";
|
||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||
import { playlistName } from "../lib/playlistName";
|
||
import { useUndoable } from "../lib/useUndoable";
|
||
import { cycleSort, type SortState } from "../lib/columnSort";
|
||
import { formatRuntime } from "../lib/format";
|
||
import { applyView, sameOrder } from "../lib/playlistView";
|
||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||
import { useComfortPin, pinVideo } from "./comfortPin";
|
||
import { ContextMenu } from "./ui/ContextMenu";
|
||
import { useContextMenu } from "./ui/useContextMenu";
|
||
import UndoToolbar from "./ui/UndoToolbar";
|
||
import NavGrid from "./ui/NavGrid";
|
||
import { PageToolbar } from "./ui/PageShell";
|
||
import { useConfirm } from "./confirmContext";
|
||
import { usePlaylistActions } from "./usePlaylistActions";
|
||
import { usePlaylists } from "./playlistsContext";
|
||
import { useMe } from "../lib/useMe";
|
||
import { usePref } from "../lib/usePref";
|
||
import { navItemAttrs } from "../lib/listNav";
|
||
import { ColumnHead } from "./ui/tableHeader";
|
||
import { alignClass } from "./ui/tableAlign";
|
||
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||
import { DragHandle } from "./ui/DragHandle";
|
||
import { Input } from "./ui/Input";
|
||
import { CheckRow } from "./ui/NativeControls";
|
||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||
|
||
// 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,
|
||
index,
|
||
columns,
|
||
dragDisabled,
|
||
dragHint,
|
||
menuProps,
|
||
}: {
|
||
video: Video;
|
||
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
|
||
index: number;
|
||
columns: Column<Video>[];
|
||
dragDisabled: boolean;
|
||
dragHint: string;
|
||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||
* behaviour — a right-click just isn't a button press. */
|
||
menuProps: Record<string, unknown>;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||
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"
|
||
{...navItemAttrs(index)}
|
||
{...menuProps}
|
||
>
|
||
{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.
|
||
<DragHandle
|
||
{...attributes}
|
||
{...(dragDisabled ? {} : listeners)}
|
||
unavailable={dragDisabled}
|
||
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
|
||
/>
|
||
) : (
|
||
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 { t } = useTranslation();
|
||
const qc = useQueryClient();
|
||
const confirm = useConfirm();
|
||
const plActions = usePlaylistActions();
|
||
const [renaming, setRenaming] = useState(false);
|
||
const renameFocus = useReturnFocus(renaming);
|
||
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;
|
||
const listId = selectedId;
|
||
api
|
||
.reorderPlaylist(
|
||
listId,
|
||
next.map((v) => v.id),
|
||
)
|
||
.then(() => {
|
||
// Write the accepted order into the cache BEFORE the refetch. The cache is what
|
||
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order
|
||
// from before this reorder — so a SECOND reorder failing in that window rolled the rows
|
||
// back to an order the server does not have, permanently (the membership guard cannot
|
||
// correct an order). One accepted write, one cache entry that matches it (round 5).
|
||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||
old ? { ...old, items: next } : old,
|
||
);
|
||
// 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(listId) });
|
||
})
|
||
.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 put back the order the server
|
||
// still has — or the rejected one becomes the base for the next drag.
|
||
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
|
||
restoreFromCache(listId);
|
||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||
});
|
||
},
|
||
});
|
||
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 selection as of NOW, for code that runs after an await or on behalf of the open player.
|
||
const selectedRef = useRef(selectedId);
|
||
selectedRef.current = selectedId;
|
||
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
|
||
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
|
||
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
|
||
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
|
||
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
|
||
function restoreFromCache(listId: number) {
|
||
if (listId !== selectedRef.current) return;
|
||
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||
if (!cur) return;
|
||
lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
|
||
order.reset(cur.items);
|
||
}
|
||
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
|
||
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
|
||
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
|
||
// dropped the launch row yanked the player off the screen mid-playback. That was a latent edge
|
||
// (a view change) until "remove this from the playlist" moved INSIDE the player, where it is the
|
||
// normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the
|
||
// same reason: the selection can change under an open player (the command palette opens over it),
|
||
// and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is
|
||
// selected by the time the button is pressed. So does whether that button exists at all
|
||
// (`editable`): gating it on the current selection offered Remove on a player launched from
|
||
// Watch later — which deliberately has none — as soon as another list was selected under it.
|
||
const [playing, setPlaying] = useState<{
|
||
video: Video;
|
||
queue: Video[];
|
||
index: number;
|
||
listId: number | null;
|
||
editable: boolean;
|
||
} | null>(null);
|
||
// Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on
|
||
// any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account
|
||
// preference like every other remembered choice. Null until the first move of this account's life.
|
||
const [lastMoveTargetId, setLastMoveTargetId] = usePref<number | null>(
|
||
"lastMovePlaylistId",
|
||
null,
|
||
);
|
||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||
const rowMenu = useContextMenu<Video>();
|
||
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
|
||
// second one on the same <tr>.
|
||
const comfortPin = useComfortPin(rowMenu.open);
|
||
|
||
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;
|
||
|
||
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
|
||
// the object around them is rebuilt every render (lib/useUndoable).
|
||
const { reset: resetOrder, rebase: rebaseOrder } = order;
|
||
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;
|
||
resetOrder(detail.items);
|
||
} else {
|
||
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
|
||
// guard above looks at the id set only, so without this the list kept showing, and handing the
|
||
// player, each row as it was when the list first loaded: watch a video to 830 s, close it,
|
||
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
|
||
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
|
||
const fresh = new Map(detail.items.map((v) => [v.id, v]));
|
||
rebaseOrder((list) => list.map((v) => fresh.get(v.id) ?? v));
|
||
}
|
||
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
|
||
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
|
||
}, [detail, selectedId, resetOrder, rebaseOrder]);
|
||
|
||
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the
|
||
// render that switches, so the incoming playlist is never drawn once through the previous one's
|
||
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
||
// render-phase write legal here.
|
||
const [viewFor, setViewFor] = useState(selectedId);
|
||
if (viewFor !== selectedId) {
|
||
setViewFor(selectedId);
|
||
setViewSort(STORED_SORT);
|
||
setFilters({});
|
||
setGroupBy(false);
|
||
}
|
||
|
||
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 canEditRef = useRef(canEdit);
|
||
canEditRef.current = canEdit;
|
||
const [pushing, setPushing] = useState(false);
|
||
const [reverting, setReverting] = useState(false);
|
||
|
||
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
||
// than closing over it.
|
||
const openPlayer = useCallback((id: string) => {
|
||
const queue = shownRef.current;
|
||
const index = queue.findIndex((v) => v.id === id);
|
||
if (index < 0) return;
|
||
setPlaying({
|
||
video: queue[index]!,
|
||
queue,
|
||
index,
|
||
listId: selectedRef.current,
|
||
editable: canEditRef.current,
|
||
});
|
||
}, []);
|
||
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
|
||
const onRemove = useCallback((v: Video) => {
|
||
void removeRef.current(v);
|
||
}, []);
|
||
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
|
||
// inline ✕ use — one confirm, one optimistic update, one undo-stack rebase, no second copy to
|
||
// drift. It answers whether the removal actually happened, because the player has to decide
|
||
// afterwards whether to step to the next item or close. It removes from the list the player was
|
||
// launched from (see `playing.listId`), not from whatever is selected by now.
|
||
const launchListId = playing?.listId ?? null;
|
||
const removeFromPlayer = useCallback(
|
||
(v: Video) => removeRef.current(v, launchListId),
|
||
[launchListId],
|
||
);
|
||
|
||
const storedIndex = useMemo(() => {
|
||
const m = new Map(items.map((v, i) => [v.id, i]));
|
||
return (v: Video) => m.get(v.id) ?? 0;
|
||
}, [items]);
|
||
|
||
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
|
||
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
|
||
const totalSeconds = useMemo(
|
||
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 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);
|
||
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
|
||
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 }),
|
||
);
|
||
|
||
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
||
const dndA11y = useSortableA11y((id) => {
|
||
const rows = shownRef.current;
|
||
return {
|
||
title: rows.find((v) => v.id === id)?.title ?? "",
|
||
pos: rows.findIndex((v) => v.id === id) + 1,
|
||
total: rows.length,
|
||
};
|
||
});
|
||
|
||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||
|
||
function refreshAll(listId: number | null = selectedId) {
|
||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||
}
|
||
|
||
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);
|
||
const nothingToDo =
|
||
plan.action === "update" &&
|
||
!plan.add_yt &&
|
||
!plan.add_local &&
|
||
!plan.remove_yt &&
|
||
!plan.remove_local &&
|
||
!plan.to_reorder &&
|
||
!plan.rename;
|
||
if (nothingToDo) {
|
||
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: string;
|
||
if (plan.action === "create") {
|
||
message = t("playlists.pushPlanCreate", {
|
||
count: plan.add_yt,
|
||
units: plan.units_estimate,
|
||
left: plan.remaining_today,
|
||
});
|
||
} else {
|
||
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||
const here: string[] = [];
|
||
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||
if (plan.remove_local)
|
||
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||
const yt: string[] = [];
|
||
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||
const sentences = [...here, ...yt];
|
||
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||
const quota =
|
||
plan.units_estimate > 0
|
||
? t("playlists.mergeQuota", {
|
||
units: plan.units_estimate,
|
||
left: plan.remaining_today,
|
||
})
|
||
: t("playlists.mergeQuotaFree");
|
||
message = sentences.join(" ") + quota;
|
||
}
|
||
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
|
||
}
|
||
|
||
/** @returns whether the item was actually removed — the player needs to know, because it must
|
||
* step to the next item (or close) only when the removal really happened. */
|
||
async function removeItem(
|
||
video: Video,
|
||
listId: number | null = selectedRef.current,
|
||
): Promise<boolean> {
|
||
if (listId == null) return false;
|
||
const ok = await confirm({
|
||
title: t("playlists.removeItem"),
|
||
message: t("playlists.confirmRemove", { title: video.title }),
|
||
confirmLabel: t("playlists.removeItem"),
|
||
danger: true,
|
||
});
|
||
if (!ok) return false;
|
||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
||
// be removing from the list it was launched from while another one is selected. Read after the
|
||
// confirm: the selection may have changed while it was open.
|
||
if (listId === selectedRef.current) {
|
||
// 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 = order.value.filter((v) => v.id !== video.id);
|
||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||
}
|
||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||
);
|
||
try {
|
||
await api.removeFromPlaylist(listId, video.id);
|
||
refreshAll(listId);
|
||
return true;
|
||
} catch (e) {
|
||
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
|
||
// full-screen player there is not even a row snapping back to show it) — unless `req` already
|
||
// put it in its error dialog, which `notifyActionError` checks.
|
||
notifyActionError(e, t("playlists.removeFailed"));
|
||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||
// removed while it was still in the playlist.
|
||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
||
refreshAll(listId);
|
||
return false;
|
||
}
|
||
}
|
||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||
const removeRef = useRef(removeItem);
|
||
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
|
||
removeRef.current = removeItem;
|
||
|
||
async function moveItemTo(video: Video, target: Playlist) {
|
||
if (selectedId == null) return;
|
||
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
|
||
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
|
||
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 {
|
||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||
// whenever the second call fails — see the endpoint's docstring.
|
||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
|
||
// shortcut to failing again.
|
||
setLastMoveTargetId(target.id);
|
||
notify({
|
||
level: "success",
|
||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||
});
|
||
} catch (e) {
|
||
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
|
||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
||
// checks (E10 review round 2 found it reported twice).
|
||
notifyActionError(e, t("playlists.moveFailed"));
|
||
restoreFromCache(selectedId); // the row is still in this list
|
||
}
|
||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||
// AddToPlaylist does for the same reason.
|
||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||
}
|
||
|
||
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
|
||
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
|
||
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
|
||
// write something the user never saw.
|
||
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
|
||
const from = items.findIndex((v) => v.id === video.id);
|
||
if (from < 0) return;
|
||
const target =
|
||
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
|
||
if (target === from || target < 0 || target >= items.length) return;
|
||
order.set(arrayMove(items, from, target)); // onApply persists
|
||
}
|
||
|
||
async function saveRename() {
|
||
if (selectedId == null) return;
|
||
const name = renameValue.trim();
|
||
setRenaming(false);
|
||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||
}
|
||
|
||
async function deletePlaylist() {
|
||
if (!detail) return;
|
||
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
|
||
// usePlaylistActions — the rail's context menu deletes through the SAME code.
|
||
await plActions.remove(detail);
|
||
}
|
||
|
||
function playerState(id: string, status: string) {
|
||
api
|
||
.setState(id, status)
|
||
.then(() => {
|
||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||
// status just changed. Only lists actually on screen refetch.
|
||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||
})
|
||
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
|
||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||
}
|
||
|
||
// Stable identity: PlayerModal's keydown 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(() => setPlaying(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");
|
||
|
||
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
|
||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||
const moveTargets = playlists.filter((p) => p.id !== selectedId);
|
||
// A one-click repeat of the last move — the submenu is two levels deep, and moving a run of videos
|
||
// into the same list is the shape this actually gets used in. Absent (not disabled) when there is
|
||
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||
const storedOrderEditable = canEdit && !viewActive;
|
||
const ctxRow = rowMenu.target;
|
||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||
? []
|
||
: [
|
||
{
|
||
id: "play",
|
||
label: t("card.play"),
|
||
icon: <Play className="w-4 h-4" />,
|
||
onSelect: () => openPlayer(ctxRow.id),
|
||
},
|
||
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
|
||
...(canEdit
|
||
? ([
|
||
...(recentMoveTarget
|
||
? [
|
||
{
|
||
id: "move-recent",
|
||
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
||
icon: <History className="w-4 h-4" />,
|
||
separatorBefore: true,
|
||
onSelect: () => void moveItemTo(ctxRow, recentMoveTarget),
|
||
},
|
||
]
|
||
: []),
|
||
moveTargets.length
|
||
? {
|
||
id: "move",
|
||
label: t("playlists.moveTo"),
|
||
icon: <FolderInput className="w-4 h-4" />,
|
||
// Only ONE separator opens the move block, wherever it starts.
|
||
separatorBefore: !recentMoveTarget,
|
||
items: moveTargets.map((pl) => ({
|
||
id: `move-${pl.id}`,
|
||
label: plName(pl),
|
||
icon:
|
||
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||
<Youtube className="w-4 h-4" />
|
||
) : (
|
||
<ListPlus className="w-4 h-4" />
|
||
),
|
||
onSelect: () => void moveItemTo(ctxRow, pl),
|
||
})),
|
||
}
|
||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||
// nobody why — and its `note` says the why, under the label.
|
||
{
|
||
id: "move",
|
||
label: t("playlists.moveTo"),
|
||
icon: <FolderInput className="w-4 h-4" />,
|
||
separatorBefore: !recentMoveTarget,
|
||
disabled: true,
|
||
note: t("playlists.moveNoTargets"),
|
||
onSelect: () => {},
|
||
},
|
||
...(
|
||
[
|
||
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
|
||
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
|
||
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
|
||
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
|
||
] as const
|
||
).map(([to, label, icon], i) => ({
|
||
id: `order-${to}`,
|
||
label,
|
||
icon,
|
||
separatorBefore: i === 0,
|
||
// Same gate as the drag handle. The reason goes under the FIRST of the four rows
|
||
// only — one explanation for the group, not the same line four times.
|
||
disabled: !storedOrderEditable,
|
||
note: i === 0 ? dragHint : undefined,
|
||
onSelect: () => moveInOrder(ctxRow, to),
|
||
})),
|
||
{
|
||
id: "remove",
|
||
label: t("playlists.removeItem"),
|
||
icon: <X className="w-4 h-4" />,
|
||
separatorBefore: true,
|
||
danger: true,
|
||
onSelect: () => void removeItem(ctxRow),
|
||
},
|
||
] satisfies ActionMenuItem[])
|
||
: []),
|
||
];
|
||
|
||
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
|
||
// another list under an open player (the command palette works over it) puts the page into
|
||
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
|
||
// unmounted it mid-playback and remounted it at the video it was launched with.
|
||
const player = playing && (
|
||
<Suspense fallback={null}>
|
||
<PlayerModal
|
||
video={playing.video}
|
||
queue={playing.queue}
|
||
startIndex={playing.index}
|
||
startAt={null}
|
||
onClose={closePlayer}
|
||
onState={playerState}
|
||
onRemoveFromList={playing.editable ? removeFromPlayer : undefined}
|
||
/>
|
||
</Suspense>
|
||
);
|
||
|
||
if (selectedId == null) {
|
||
return (
|
||
<>
|
||
<div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>
|
||
{player}
|
||
</>
|
||
);
|
||
}
|
||
// The page used to render "Loading…" forever when the detail query failed — an error read as a
|
||
// slow network that never arrived. Only with nothing to show, though (the `isError && <no data>`
|
||
// convention, see ui/QueryState): a background refetch that failed — on focus, after a move or a
|
||
// removal — replaced a list that was on screen with this message.
|
||
if (detailQuery.isError && !detail) {
|
||
return (
|
||
<>
|
||
<StateMessage tone="error" onRetry={() => detailQuery.refetch()}>
|
||
{t("playlists.loadFailed")}
|
||
</StateMessage>
|
||
{player}
|
||
</>
|
||
);
|
||
}
|
||
if (!detail) {
|
||
return (
|
||
<>
|
||
<LoadingState shape="list" label={t("playlists.loading")} />
|
||
{player}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||
return (
|
||
<>
|
||
<div className="px-4 pb-4">
|
||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||
fixed band, so only the video list scrolls. */}
|
||
<PageToolbar>
|
||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||
row it sheds is list height won back — the old layout stacked three rows next to the
|
||
cover plus a fourth block under it, for roughly twice this height. */}
|
||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||
<div className="flex gap-3 items-center">
|
||
<div className="w-20 h-11.25 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">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{renaming ? (
|
||
<Input
|
||
className="flex-1 text-lg font-semibold"
|
||
size="heading"
|
||
autoFocus
|
||
value={renameValue}
|
||
onChange={(e) => setRenameValue(e.target.value)}
|
||
onBlur={saveRename}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
renameFocus.arm(e);
|
||
saveRename();
|
||
}
|
||
if (e.key === "Escape") {
|
||
renameFocus.arm(e);
|
||
setRenaming(false);
|
||
}
|
||
}}
|
||
aria-label={t("playlists.rename")}
|
||
/>
|
||
) : (
|
||
<>
|
||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||
{editable && (
|
||
<IconButton
|
||
ref={renameFocus.target}
|
||
onClick={() => {
|
||
setRenameValue(detail.name);
|
||
setRenaming(true);
|
||
}}
|
||
label={t("playlists.rename")}
|
||
>
|
||
<Pencil />
|
||
</IconButton>
|
||
)}
|
||
{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 className="ml-auto flex gap-1.5 items-center shrink-0">
|
||
{linked && detail.dirty && (
|
||
<span
|
||
title={t("playlists.unsyncedHint")}
|
||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||
>
|
||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||
{t("playlists.unsynced")}
|
||
</span>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||
disabled={!shown.length}
|
||
icon={<Play className="fill-current" />}
|
||
>
|
||
{t("playlists.playAll")}
|
||
</Button>
|
||
{editable && canWrite && (
|
||
<IconButton
|
||
variant={detail.dirty ? "outline-accent" : "outline"}
|
||
onClick={pushToYoutube}
|
||
loading={pushing}
|
||
label={
|
||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||
}
|
||
>
|
||
{linked ? <Youtube /> : <Upload />}
|
||
</IconButton>
|
||
)}
|
||
{linked && detail.dirty && (
|
||
<IconButton
|
||
label={t("playlists.revert")}
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={revertToYoutube}
|
||
disabled={reverting}
|
||
>
|
||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||
</IconButton>
|
||
)}
|
||
{editable && (
|
||
<IconButton
|
||
label={t("playlists.delete")}
|
||
size="sm"
|
||
variant="outline-danger"
|
||
onClick={deletePlaylist}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</IconButton>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||
<span className="text-xs text-muted">
|
||
{t("playlists.itemCount", { count: items.length })}
|
||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||
</span>
|
||
{(items.length > 1 || viewActive) && (
|
||
<>
|
||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||
·
|
||
</span>
|
||
<CheckRow
|
||
checked={groupBy}
|
||
onChange={setGroupBy}
|
||
className="text-xs text-muted"
|
||
>
|
||
{t("playlists.groupByChannel")}
|
||
</CheckRow>
|
||
{viewActive && (
|
||
<Button
|
||
variant="link-muted"
|
||
onClick={clearView}
|
||
className="text-xs underline underline-offset-2"
|
||
>
|
||
{t("playlists.clearView")}
|
||
</Button>
|
||
)}
|
||
{canSaveOrder && (
|
||
<Button
|
||
variant="outline-accent"
|
||
size="xs"
|
||
onClick={saveOrder}
|
||
icon={<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-2xs 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>
|
||
</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={dndA11y}
|
||
>
|
||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||
<div className="max-w-5xl mx-auto">
|
||
{/* 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}
|
||
>
|
||
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
|
||
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
|
||
dnd-kit has handled). */}
|
||
<NavGrid as="tbody">
|
||
{shown.map((v, i) => (
|
||
<Row
|
||
key={v.id}
|
||
index={i}
|
||
video={v}
|
||
columns={columns}
|
||
dragDisabled={!canEdit || viewActive}
|
||
dragHint={dragHint}
|
||
menuProps={rowMenu.triggerProps(v)}
|
||
/>
|
||
))}
|
||
</NavGrid>
|
||
</SortableContext>
|
||
</table>
|
||
{shown.length === 0 && (
|
||
<div className="text-muted py-8 text-center text-sm">
|
||
{t("playlists.noneMatchFilter")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DndContext>
|
||
)}
|
||
|
||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||
</div>
|
||
{player}
|
||
</>
|
||
);
|
||
}
|