import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react"; import { YouTubeIcon } from "../lib/brandIcons"; import { api, type Playlist } from "../lib/api"; import { compareText } from "../lib/collate"; import { formatRuntime } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS } from "../lib/storage"; import { usePref } from "../lib/usePref"; import { type PanelLayout } from "../lib/panelLayout"; import FilterRail from "./FilterRail"; import { Button } from "./ui/Button"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { useComfortPin, pinPlaylist } from "./comfortPin"; import { ContextMenu } from "./ui/ContextMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { type PanelItem } from "./ui/PanelGroups"; import SortControl from "./ui/SortControl"; import { type SortOption } from "../lib/columnSort"; import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; import { useMe } from "../lib/useMe"; import { playlistName } from "../lib/playlistName"; import { usePlaylistActions } from "./usePlaylistActions"; import PlaylistLimitsHint from "./PlaylistLimitsHint"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { MoreButton } from "./ui/MoreButton"; import RemoteImage from "./ui/RemoteImage"; type PlSort = { key: "custom" | "name" | "count" | "duration" | "recent"; dir: "asc" | "desc"; dirtyFirst: boolean; }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; // One geometry for the row in both of its shapes (the selectable button, and the rename field it // becomes) — the row must not resize the moment you rename it. const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition"; // The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the // shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail // controls (sort / new / sync); the selected playlist is App state so the module's detail pane // (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. export default function PlaylistsRail({ layout, setLayout, collapsed, onToggleCollapse, }: { layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }) { const canWrite = useMe().can_write; // The name filter + the selected playlist live in PlaylistsProvider (shared with the header search // box and the Playlists detail pane); this rail owns the list query + its own controls. const { search, selectedId } = usePlaylists(); const { setSelectedId } = usePlaylistsActions(); const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const [plSort, setPlSort] = usePref("plSort", PL_SORT_DEFAULT, { localKey: LS.plSort, parse: (raw) => ({ ...PL_SORT_DEFAULT, ...(raw && typeof raw === "object" ? raw : {}) }), }); const plSortOptions: SortOption[] = [ { key: "custom", label: t("playlists.railSortCustom"), directionless: true }, { key: "name", label: t("playlists.railSortName"), defaultDir: "asc" }, { key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" }, { key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" }, // "the one I just touched, first". Reads `updated_at`, which the API bumps for item // adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the // default: a rail that reshuffles itself while you look at it costs more than it saves. { key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" }, ]; const [newName, setNewName] = useState(""); const [syncing, setSyncing] = useState(false); const plActions = usePlaylistActions(); // Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu). const railMenu = useContextMenu(); // The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only // have one right-click menu, and rename/delete/pin are all things you do to the same playlist. const comfortPin = useComfortPin(railMenu.open); // Renaming happens IN the row that was right-clicked, not in the detail header — the point of the // context menu is that the action lands where the gesture did. const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(""); const selectedRef = useRef(null); const scrolledRef = useRef(false); const listQuery = useQuery({ queryKey: qk.playlists(), queryFn: () => api.playlists(), refetchOnWindowFocus: true, }); // Memoised so the effect and the sort below depend on a value that only changes with the query — // `?? []` would otherwise mint a new empty array on every render while the list is loading. const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]); // Keep the selection valid: default to the first playlist, and if the selected one disappears // drop to the next available (or clear when none remain). Waits for the first load. useEffect(() => { if (!listQuery.data) return; if (!playlists.length) { if (selectedId != null) setSelectedId(null); return; } if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns) } }, [playlists, selectedId, listQuery.data, setSelectedId]); // useCallback because the two memos below sort by it: as a fresh closure each render it could not // be named in their dep lists, and `t` — which is what actually changes it, on a language switch — // was therefore invisible to them. A rename in the other language now re-sorts. const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]); const sortedPlaylists = useMemo(() => { const sign = plSort.dir === "asc" ? 1 : -1; return [...playlists].sort((a, b) => { if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; switch (plSort.key) { case "name": return sign * compareText(plName(a), plName(b), lang); case "count": return sign * (a.item_count - b.item_count); case "duration": return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); case "recent": return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at)); default: return 0; } }); }, [playlists, plSort, plName, lang]); const q = search.trim().toLowerCase(); const visiblePlaylists = useMemo( () => q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists, [sortedPlaylists, q, plName], ); useEffect(() => { if (!scrolledRef.current && selectedRef.current) { selectedRef.current.scrollIntoView({ block: "nearest" }); scrolledRef.current = true; } }, [sortedPlaylists]); async function syncYoutube() { if (syncing) return; setSyncing(true); try { const r = await api.syncYoutubePlaylists(); qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlist() }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); } catch (e) { notifyYouTubeActionError(e, t("playlists.syncFailed")); } setSyncing(false); } const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), meta: { failTitle: "errors.writeFailed.createPlaylist" }, onSuccess: (pl: Playlist) => { setNewName(""); qc.invalidateQueries({ queryKey: qk.playlists() }); setSelectedId(pl.id); }, }); // Enter and Escape unmount the focused and re-render the row as a NEW {playlists.length > 1 && ( next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir }) } trailing={ setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })} label={t("playlists.dirtyFirst")} pressed={plSort.dirtyFirst} > } /> )}
{ e.preventDefault(); if (newName.trim()) createMut.mutate(newName.trim()); }} className="flex items-center gap-1.5" > setNewName(e.target.value)} placeholder={t("playlists.newPlaylist")} disabled={!canWrite} /> ), }, { id: "list", title: t("playlists.listGroup"), body: ( <> {playlists.length === 0 && (listQuery.isError && !listQuery.data ? ( listQuery.refetch()} className="text-xs text-danger px-1 py-1 hover:underline" > {t("common.loadError")} — {t("common.retry")} ) : listQuery.isLoading ? (
{t("common.loading")}
) : (
{t("playlists.noneYet")}
))} {playlists.length > 0 && visiblePlaylists.length === 0 && (
{t("playlists.noMatches")}
)}
{visiblePlaylists.map((pl) => renamingId === pl.id ? ( // The row becomes its own rename field. A
), }, ]; return ( } collapsed={collapsed} onToggleCollapse={onToggleCollapse} layout={layout} setLayout={setLayout} items={items} /> ); }