import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, } from "react"; import { useLatest, useStableCallback } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { hasResumePoint } from "../../lib/api/feed"; import { movePlaylistItems, removePlaylistItems, restorePlaylistItems, undoPlaylistMove, clearVideoProgress, writeVideoState, } from "../../features/video/videoWrites"; import { DndContext, KeyboardSensor, PointerSensor, closestCenter, DragOverlay, KeyboardCode, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type DragStartEvent, type Modifier, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS, getEventCoordinates } from "@dnd-kit/utilities"; import { Check, Eraser, 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 { msg, notify, raw } from "../../lib/notifications"; import { notifyActionError } from "../../lib/actionErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { playlistName, playlistNameText } from "../../lib/playlistName"; import { reinsertAt } from "../../lib/reinsert"; import { useRowSelection } from "../../lib/useRowSelection"; 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 Overlay from "../../components/ui/Overlay"; 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 { followRemoval, 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, selectColumn } from "./playlistColumns"; import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; import { DragHandle } from "../../components/ui/DragHandle"; import { SURFACE } from "../../components/ui/surfaces"; import { RowSelectBox } from "../../components/ui/RowSelectBox"; 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"; import { SelectionBand } from "./SelectionBand"; import { isOverRail, railDropTarget, startRailDrag, stopRailDrag } from "./railDrop"; import clsx from "clsx"; // 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(","); const moveTargetIcon = (pl: Playlist) => pl.source === "youtube" || pl.yt_playlist_id ? ( ) : ( ); /** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to. * No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency * list, whose size must not change. */ const NO_KEY_STARTS = { start: [], cancel: [KeyboardCode.Esc], end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab], }; /** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small * box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */ function pointerFirst(args: Parameters[0]): Parameters[0] { const at = args.pointerCoordinates; if (!at) return args; const { width, height } = args.collisionRect; const left = at.x - width / 2; const top = at.y - height / 2; return { ...args, collisionRect: { width, height, left, top, right: left + width, bottom: top + height }, }; } /** The drag image hangs below the pointer: on it, it covered the name of the list it was over. */ const beneathPointer: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => { const at = activatorEvent && getEventCoordinates(activatorEvent); if (!at || !draggingNodeRect) return transform; return { ...transform, x: transform.x + at.x - draggingNodeRect.left + 12, y: transform.y + at.y - draggingNodeRect.top + 16, }; }; function Row({ video, index, columns, selected, carried, dragDisabled, listDragOnly, dragHint, menuProps, }: { video: Video; /** Its position among the rows shown — the row is a keyboard item (lib/listNav). */ index: number; columns: Column