Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
1122 lines
51 KiB
TypeScript
1122 lines
51 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,
|
||
GripVertical,
|
||
ListPlus,
|
||
Pencil,
|
||
Play,
|
||
RefreshCw,
|
||
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 { FocusSafeButton } from "./ui/FocusSafeButton";
|
||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||
|
||
// 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.
|
||
<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 { t } = useTranslation();
|
||
const qc = useQueryClient();
|
||
const confirm = useConfirm();
|
||
const plActions = usePlaylistActions();
|
||
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;
|
||
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 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
|
||
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="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||
/>
|
||
) : (
|
||
<>
|
||
<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 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>
|
||
)}
|
||
<FocusSafeButton
|
||
onClick={() => shown.length && openPlayer(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 btn-primary disabled:opacity-40 transition"
|
||
>
|
||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||
</FocusSafeButton>
|
||
{editable && canWrite && (
|
||
<FocusSafeButton
|
||
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" />
|
||
)}
|
||
</FocusSafeButton>
|
||
)}
|
||
{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>
|
||
<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
|
||
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}
|
||
</>
|
||
);
|
||
}
|