feat(ui): unified floating side panels for filters and playlists

Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
This commit is contained in:
npeter83 2026-07-13 05:13:40 +02:00
parent 7f358f63e3
commit d92e487cf4
18 changed files with 1111 additions and 872 deletions

View file

@ -1,6 +1,6 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
PointerSensor,
@ -23,9 +23,7 @@ import {
GripVertical,
ListPlus,
Pencil,
Pin,
Play,
Plus,
RefreshCw,
RotateCcw,
Trash2,
@ -33,11 +31,10 @@ import {
X,
Youtube,
} from "lucide-react";
import { api, type Playlist, type Video } from "../lib/api";
import { api, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
@ -95,9 +92,6 @@ const idsKey = (items: Video[]) =>
.sort()
.join(",");
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
function Row({
video,
index,
@ -185,31 +179,18 @@ function Row({
export default function Playlists({
canWrite,
search,
selectedId,
setSelectedId,
}: {
canWrite: boolean;
// The playlist-name filter, from the shared top SearchBar (App-owned state).
search: string;
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
selectedId: number | null;
setSelectedId: (id: number | null) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
useEffect(() => {
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The item order is undoable: drag, sort and group all go through `order.set`, so each is
@ -244,21 +225,6 @@ export default function Playlists({
});
const playlists = listQuery.data ?? [];
// Keep the selection valid: default to the first playlist, and if the selected one
// disappears (e.g. just deleted) drop to the next available, or clear when none remain.
useEffect(() => {
// Wait for the first load before adjusting the selection — otherwise the empty
// placeholder list would null the restored selection and we'd fall back to the first.
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, selectedId, listQuery.data]);
const detailQuery = useQuery({
queryKey: ["playlist", selectedId],
queryFn: () => api.playlist(selectedId as number),
@ -308,48 +274,11 @@ export default function Playlists({
const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
// The left rail in the chosen order. "custom" keeps the server position order (Array.sort
// is stable); "dirty first" floats playlists with unpushed edits to the top.
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; // custom: server order
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
// Client-side name filter driven by the shared top SearchBar.
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]
);
// Scroll the restored selection into view once after the rail first renders.
useEffect(() => {
if (!scrolledRef.current && selectedRef.current) {
selectedRef.current.scrollIntoView({ block: "nearest" });
scrolledRef.current = true;
}
}, [sortedPlaylists]);
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;
const [syncing, setSyncing] = useState(false);
const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
@ -428,30 +357,6 @@ export default function Playlists({
}
}
async function syncYoutube() {
if (syncing) return;
setSyncing(true);
try {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["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: ["playlists"] });
setSelectedId(pl.id);
},
});
function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
@ -538,123 +443,7 @@ export default function Playlists({
}
return (
<div className="flex h-full min-h-0">
<aside className="w-64 shrink-0 glass overflow-y-auto p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">{t("playlists.title")}</span>
<button
onClick={syncYoutube}
disabled={syncing}
title={t("playlists.syncYoutube")}
aria-label={t("playlists.syncYoutube")}
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
<Youtube className="w-3.5 h-3.5" />
</button>
</div>
{playlists.length > 1 && (
<div className="flex items-center gap-1 mb-2">
<select
value={plSort.key}
onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
}
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() =>
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
}
disabled={plSort.key === "custom"}
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className={`p-1 rounded-md border transition ${
plSort.dirtyFirst
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mb-3 pb-3 border-b border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto px-4 pb-4 pt-[var(--hdr-h)]">
<div className="px-4 pb-4">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
@ -869,7 +658,6 @@ export default function Playlists({
)}
</>
)}
</main>
{playingIndex != null && items[playingIndex] && (
<Suspense fallback={null}>