- useUrlMirror: a change before the user has done anything since the load is the page settling (the rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload mark stays, and the player that reopens takes its entry over — one Back closes it, as before. - useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the address names under that module's key (the card's push kept the season's `v` beside its `m`). - Playlists: the rail's default selection and the page's "the rail moved there" read one helper. - moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census walks type arguments of any depth and refuses an aliased import. - A stale comment on the nav press over a live search.
1146 lines
52 KiB
TypeScript
1146 lines
52 KiB
TypeScript
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../../lib/queryKeys";
|
|
import {
|
|
addPlaylistItem,
|
|
movePlaylistItem,
|
|
removePlaylistItem,
|
|
writeVideoState,
|
|
} from "../../features/video/videoWrites";
|
|
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 { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react";
|
|
import { YouTubeIcon } from "../../lib/brandIcons";
|
|
import {
|
|
api,
|
|
type Playlist,
|
|
type PlaylistDetail,
|
|
type Video,
|
|
type VideoStatus,
|
|
} from "../../lib/api";
|
|
import { notify } from "../../lib/notifications";
|
|
import { notifyActionError } from "../../lib/actionErrors";
|
|
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
|
import { playlistName } from "../../lib/playlistName";
|
|
import { reinsertAt } from "../../lib/reinsert";
|
|
import { useUndoable } from "../../lib/useUndoable";
|
|
import { cycleSort, type SortState } from "../../lib/columnSort";
|
|
import { useUrlMirror } from "../../lib/useUrlMirror";
|
|
import {
|
|
isStoredSort,
|
|
PLAYLIST_STORED_SORT,
|
|
playlistViewFrom,
|
|
playlistViewParamUpdates,
|
|
} from "../../lib/playlistsUrlState";
|
|
import { formatRuntime } from "../../lib/format";
|
|
import { applyView, sameOrder } from "../../lib/playlistView";
|
|
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
|
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
|
import { Button } from "../../components/ui/Button";
|
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
|
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
|
|
import { ContextMenu } from "../../components/ui/ContextMenu";
|
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
|
import { usePageMenu } from "../../components/ui/pageMenuSlot";
|
|
import { menuGroups } from "../../components/ui/menuGroups";
|
|
import UndoToolbar from "../../components/ui/UndoToolbar";
|
|
import NavGrid from "../../components/ui/NavGrid";
|
|
import { PageToolbar } from "../../components/ui/PageShell";
|
|
import { usePlaylistActions } from "./usePlaylistActions";
|
|
import { usePlaylists } from "./playlistsContext";
|
|
import { railDefaultId } from "./playlistsInit";
|
|
import { usePref } from "../../lib/usePref";
|
|
import { navItemAttrs } from "../../lib/listNav";
|
|
import { ColumnHead } from "../../components/ui/tableHeader";
|
|
import { alignClass } from "../../components/ui/tableAlign";
|
|
import {
|
|
columnHideClass,
|
|
filterRows,
|
|
isActive,
|
|
type Column,
|
|
type FilterMap,
|
|
} from "../../lib/tableColumns";
|
|
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
|
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
|
import { DragHandle } from "../../components/ui/DragHandle";
|
|
import { useOrderItems } from "../../components/ui/useOrderItems";
|
|
import { Input } from "../../components/ui/Input";
|
|
import { CheckRow } from "../../components/ui/NativeControls";
|
|
import { useReturnFocus } from "../../components/ui/useReturnFocus";
|
|
import { MoreButton } from "../../components/ui/MoreButton";
|
|
import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
|
|
import { PageHeading } from "../../components/ui/Headings";
|
|
import { useCopyItems } from "../../components/ui/copyItems";
|
|
import RemoteImage from "../../components/ui/RemoteImage";
|
|
import PlaylistsHeader from "./PlaylistsHeader";
|
|
|
|
// The playlist detail is a HYBRID table: 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. At first 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, the stored order) lives beside the address-bar codec of the view.
|
|
const STORED_SORT = PLAYLIST_STORED_SORT;
|
|
|
|
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).
|
|
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
|
className="group border-b border-border/50 state-layer 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)}${columnHideClass(col)}`}
|
|
>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// The page: its header pill, and the selected playlist's detail (whose loading and empty states
|
|
// return early, so the header sits outside it).
|
|
export default function Playlists() {
|
|
return (
|
|
<>
|
|
<PlaylistsHeader />
|
|
<PlaylistDetail />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PlaylistDetail() {
|
|
// 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, linkedId } = usePlaylists();
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const plActions = usePlaylistActions();
|
|
const [renaming, setRenaming] = useState(false);
|
|
const { target: renameTarget, arm: armRenameReturn } = 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.
|
|
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. The address names it, so a reload or a
|
|
// bookmark opens the playlist sorted, filtered and grouped as it was left (the view it opens on is
|
|
// the address's; a switch to another playlist starts over, below).
|
|
const [opened] = useState(() => playlistViewFrom(new URLSearchParams(window.location.search)));
|
|
const [viewSort, setViewSort] = useState<SortState>(opened.sort);
|
|
const [filters, setFilters] = useState<FilterMap>(opened.filters);
|
|
const [groupBy, setGroupBy] = useState(opened.groupBy);
|
|
// 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 = useLatest(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.
|
|
// 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 rather than mounting a
|
|
// second one on the same <tr>.
|
|
const comfortPin = useComfortPin(rowMenu.open);
|
|
const copy = useCopyItems();
|
|
// The header's "…": the list's own menu, the one its rail row opens (usePlaylistActions.items).
|
|
const headerMenu = useContextMenu<PlaylistDetail>();
|
|
const headerPin = useComfortPin(headerMenu.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;
|
|
// Which kind of list an item write goes to (the Watch later list's items are the bookmark):
|
|
// the rail's list knows every one, the open detail the one on screen. Only a user list's rows are
|
|
// removed or moved from here today (Watch later offers neither), so it reads "user" — looked up
|
|
// all the same, so no write here takes the kind for granted. A move INTO Watch later passes the
|
|
// rail's own row as its target.
|
|
const listRef = (id: number) => ({
|
|
id,
|
|
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
|
|
});
|
|
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
|
|
const pageMenu = usePageMenu(selectedId != null && !!detail);
|
|
const pagePin = useComfortPin(pageMenu.open);
|
|
|
|
// 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);
|
|
}
|
|
useUrlMirror(
|
|
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
|
|
);
|
|
|
|
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 header's "Unsynced" says when they differ.
|
|
const editable = !builtin;
|
|
const linked = editable && !!detail?.yt_playlist_id;
|
|
// LOCAL editing (reorder, remove) needs no YouTube write scope — `can_write` gates only the
|
|
// push/delete-on-YouTube paths (usePlaylistActions). 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 = useLatest(canEdit);
|
|
// Forward refs to `shown` and `removeItem` (defined further down) for the stable callbacks here,
|
|
// pointed at the latest values after each commit (below).
|
|
const shownRef = useRef<Video[]>([]);
|
|
const removeRef = useRef<typeof removeItem>(async () => false);
|
|
|
|
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
|
// than closing over it.
|
|
const openPlayer = useStableCallback((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,
|
|
});
|
|
});
|
|
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
|
|
// Delete key use — one optimistic update, one undo toast, 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 = (v: Video) => removeRef.current(v, 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],
|
|
);
|
|
|
|
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the
|
|
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
|
|
const { buttonProps: rowMenuButton, open: rowMenuOpen, target: rowMenuTarget } = rowMenu;
|
|
const columns = useMemo(
|
|
() =>
|
|
playlistColumns({
|
|
t,
|
|
indexOf: storedIndex,
|
|
onPlay: (v) => openPlayer(v.id),
|
|
more: (v) => (
|
|
<MoreButton
|
|
label={t("playlists.rowMenu")}
|
|
expanded={rowMenuOpen && rowMenuTarget === v}
|
|
menuProps={rowMenuButton(v)}
|
|
className={rowMenuOpen && rowMenuTarget === v ? undefined : "reveal"}
|
|
/>
|
|
),
|
|
}),
|
|
[t, storedIndex, openPlayer, rowMenuButton, rowMenuOpen, rowMenuTarget],
|
|
);
|
|
|
|
const filterActive = Object.values(filters).some(isActive);
|
|
const shown = useMemo(
|
|
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
|
|
[items, columns, filters, viewSort, groupBy, lang],
|
|
);
|
|
|
|
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) });
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
|
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
|
* @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, listIdArg?: number | null): Promise<boolean> {
|
|
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
|
|
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
|
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
|
if (listId == null) 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.
|
|
if (listId === selectedRef.current) {
|
|
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that 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));
|
|
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
|
|
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
|
|
// not shift it off by the rows added or moved meanwhile).
|
|
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
|
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
|
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
|
);
|
|
try {
|
|
await removePlaylistItem(qc, listRef(listId), video.id);
|
|
refreshAll(listId);
|
|
} catch (e) {
|
|
// Said here, as moveItemTo does, or a 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;
|
|
}
|
|
notify({
|
|
level: "success",
|
|
message: t("playlists.removedToast", { title: video.title }),
|
|
action: {
|
|
label: t("common.undo"),
|
|
// ONE request that puts it back at its index (the server renumbers around it): an append
|
|
// plus a whole-list reorder would land it at the end whenever the second call failed. The
|
|
// index is worked out against the list as it is NOW, fetched fresh.
|
|
onClick: () =>
|
|
void qc
|
|
.fetchQuery({
|
|
queryKey: qk.playlist(listId),
|
|
queryFn: () => api.playlist(listId),
|
|
staleTime: 0,
|
|
})
|
|
.then((now) => {
|
|
const back = reinsertAt(
|
|
storedBefore,
|
|
[video.id],
|
|
now.items.map((v) => v.id),
|
|
);
|
|
if (back.keys.length)
|
|
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
|
})
|
|
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
|
|
.finally(() => {
|
|
refreshAll(listId);
|
|
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
|
}),
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
|
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
|
useLayoutEffect(() => {
|
|
shownRef.current = shown;
|
|
removeRef.current = removeItem;
|
|
});
|
|
// A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue
|
|
// again (the playlist and its view are in the address under it). A video the view does not show
|
|
// (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
|
|
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
|
|
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
|
|
// Linked over the playlist the store started with, not the selection this page mounted on: the rail
|
|
// may have moved that already. One the account's list does not hold (deleted since, another
|
|
// account's link) is out of reach as soon as that list is in: the rail then moves to the first one
|
|
// by itself, which is no pick — any other is.
|
|
const linkedOver = linkedId;
|
|
const linkedOutOfReach =
|
|
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
|
|
const railMovedTo = railDefaultId(listQuery.data);
|
|
const linkedListIn =
|
|
linkedOutOfReach ||
|
|
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
|
|
(selectedId != null && detailQuery.isError);
|
|
useOpenLinkedPlayer(
|
|
linkedListIn,
|
|
(id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)),
|
|
(video, inList) => {
|
|
if (inList) openPlayer(video.id);
|
|
else {
|
|
const listId = linkedOutOfReach ? null : selectedId;
|
|
setPlaying({ video, queue: [video], index: 0, listId, editable: false });
|
|
}
|
|
},
|
|
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
|
|
);
|
|
|
|
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 movePlaylistItem(qc, listRef(selectedId), video.id, target);
|
|
// 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.
|
|
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
|
// checks (a review 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.
|
|
const orderItems = useOrderItems();
|
|
function moveInOrder(video: Video, to: number) {
|
|
const from = items.findIndex((v) => v.id === video.id);
|
|
if (from < 0 || to === from) return;
|
|
order.set(arrayMove(items, from, to)); // 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);
|
|
}
|
|
|
|
function playerState(id: string, status: VideoStatus) {
|
|
const write = writeVideoState(qc, id, status);
|
|
write
|
|
.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.
|
|
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
|
return write;
|
|
}
|
|
|
|
// 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 = () => 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 ?? ""),
|
|
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
|
|
...it,
|
|
separatorBefore: i === 0,
|
|
})),
|
|
...(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 ? (
|
|
<YouTubeIcon 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: () => {},
|
|
},
|
|
// Same gate as the drag handle; the reason goes under the first of the four rows.
|
|
...orderItems(
|
|
items.findIndex((v) => v.id === ctxRow.id),
|
|
items.length,
|
|
(to) => moveInOrder(ctxRow, to),
|
|
storedOrderEditable ? undefined : { reason: dragHint },
|
|
),
|
|
{
|
|
id: "remove",
|
|
label: t("playlists.removeItem"),
|
|
icon: <X className="w-4 h-4" />,
|
|
separatorBefore: true,
|
|
danger: true,
|
|
onSelect: () => void removeItem(ctxRow),
|
|
},
|
|
] satisfies ActionMenuItem[])
|
|
: []),
|
|
];
|
|
|
|
const headerPl = headerMenu.target;
|
|
const headerItems: ActionMenuItem[] = !headerPl
|
|
? []
|
|
: plActions.items(headerPl, {
|
|
onRename: () => {
|
|
setRenameValue(headerPl.name);
|
|
setRenaming(true);
|
|
},
|
|
pin: headerPin.item(pinPlaylist(headerPl.id), plName(headerPl)),
|
|
});
|
|
|
|
// The page's background menu mirrors the band: Play all, the view controls, the order's undo, and
|
|
// the list's own "…" without its destructive rows (Delete, Revert) — a stray right-click on the
|
|
// page's blank area must not be one click from those.
|
|
const pageItems: ActionMenuItem[] = !detail
|
|
? []
|
|
: menuGroups(
|
|
[
|
|
{
|
|
id: "playAll",
|
|
label: t("playlists.playAll"),
|
|
disabled: !shown.length,
|
|
onSelect: () => shown.length && openPlayer(shown[0]!.id),
|
|
},
|
|
],
|
|
items.length > 1 || viewActive
|
|
? [
|
|
{
|
|
id: "groupBy",
|
|
label: t("playlists.groupByChannel"),
|
|
check: { kind: "many", checked: groupBy },
|
|
onSelect: () => setGroupBy(!groupBy),
|
|
},
|
|
...(viewActive
|
|
? [{ id: "clearView", label: t("playlists.clearView"), onSelect: clearView }]
|
|
: []),
|
|
...(canSaveOrder
|
|
? [{ id: "saveOrder", label: t("playlists.saveOrder"), onSelect: saveOrder }]
|
|
: []),
|
|
]
|
|
: [],
|
|
canEdit && (order.canUndo || order.canRedo)
|
|
? [
|
|
...(order.canUndo
|
|
? [{ id: "undo", label: t("common.undo"), onSelect: order.undo }]
|
|
: []),
|
|
...(order.canRedo
|
|
? [{ id: "redo", label: t("common.redo"), onSelect: order.redo }]
|
|
: []),
|
|
]
|
|
: [],
|
|
plActions
|
|
.items(detail, {
|
|
onRename: () => {
|
|
setRenameValue(detail.name);
|
|
setRenaming(true);
|
|
},
|
|
pin: pagePin.item(pinPlaylist(detail.id), plName(detail)),
|
|
})
|
|
.filter((it) => !("danger" in it && it.danger)),
|
|
);
|
|
|
|
// 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 && (
|
|
<RemoteImage
|
|
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") {
|
|
armRenameReturn(e);
|
|
void saveRename(); // plActions.rename reports its own failure
|
|
}
|
|
if (e.key === "Escape") {
|
|
armRenameReturn(e);
|
|
setRenaming(false);
|
|
}
|
|
}}
|
|
aria-label={t("playlists.rename")}
|
|
/>
|
|
) : (
|
|
<PageHeading className="text-lg font-semibold truncate">
|
|
{plName(detail)}
|
|
</PageHeading>
|
|
)}
|
|
<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>
|
|
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
|
|
menu, the same the rail row opens — the header keeps Play all. */}
|
|
<MoreButton
|
|
ref={renameTarget}
|
|
variant="outline"
|
|
label={t("playlists.railMenu")}
|
|
expanded={headerMenu.open}
|
|
menuProps={headerMenu.buttonProps(detail)}
|
|
/>
|
|
</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, lang)}`}
|
|
{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. A container,
|
|
so a narrow column hides by the table's width, not the window's (`narrowHidden`). */}
|
|
<div className="@container max-w-5xl mx-auto">
|
|
{/* A narrow table drops Channel and its funnel with it: the cue keeps its filter named. */}
|
|
<HiddenFilterCue
|
|
columns={columns}
|
|
filters={filters}
|
|
by="width"
|
|
onFilter={applyFilter}
|
|
/>
|
|
{/* 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"
|
|
// Delete on a walked row = the menu's Remove, with the same undo toast.
|
|
onDelete={
|
|
canEdit
|
|
? (i) => {
|
|
const v = shownRef.current[i];
|
|
if (v) void removeItem(v);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{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} />
|
|
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
|
|
<ContextMenu
|
|
menu={pageMenu}
|
|
ariaLabel={t("common.pageMenu")}
|
|
items={pageMenu.open ? pageItems : []}
|
|
/>
|
|
</div>
|
|
{player}
|
|
</>
|
|
);
|
|
}
|