import { 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 { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS, readAccountMerged, writeAccount } from "../lib/storage"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; import { useMe } from "../lib/useMe"; import { playlistName } from "../lib/playlistName"; type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean; }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; // 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 } = useTranslation(); const qc = useQueryClient(); const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); useEffect(() => { writeAccount(LS.plSort, plSort); }, [plSort]); const [newName, setNewName] = useState(""); const [syncing, setSyncing] = useState(false); const [editing, setEditing] = useState(false); const selectedRef = useRef(null); const scrolledRef = useRef(false); const listQuery = useQuery({ queryKey: qk.playlists(), queryFn: () => api.playlists(), refetchOnWindowFocus: true, }); const playlists = 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) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlists, selectedId, listQuery.data]); const plName = (p: { kind: string; name: string }) => playlistName(p, 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 * plName(a).localeCompare(plName(b)); case "count": return sign * (a.item_count - b.item_count); case "duration": return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); default: return 0; } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlists, plSort]); const q = search.trim().toLowerCase(); const visiblePlaylists = useMemo( () => q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists, // eslint-disable-next-line react-hooks/exhaustive-deps [sortedPlaylists, q], ); 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")); } finally { setSyncing(false); } } const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), onSuccess: (pl: Playlist) => { setNewName(""); qc.invalidateQueries({ queryKey: qk.playlists() }); setSelectedId(pl.id); }, }); const items: PanelItem[] = [ { id: "manage", title: t("playlists.manageGroup"), body: (
{playlists.length > 1 && (
)}
{ 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} className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent disabled:opacity-50" />
), }, { id: "list", title: t("playlists.listGroup"), body: ( <> {playlists.length === 0 && (listQuery.isError && !listQuery.data ? ( ) : listQuery.isLoading ? (
{t("common.loading")}
) : (
{t("playlists.noneYet")}
))} {playlists.length > 0 && visiblePlaylists.length === 0 && (
{t("playlists.noMatches")}
)}
{visiblePlaylists.map((pl) => ( ))}
), }, ]; return ( } collapsed={collapsed} onToggleCollapse={onToggleCollapse} editing={editing} onToggleEditing={() => setEditing((e) => !e)} onReset={() => setLayout(defaultLayout("playlists"))} editHint={t("sidebar.editHint")} > ); }