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:
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
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({
|
||||
canWrite,
|
||||
search,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
search: string;
|
||||
selectedId: number | null;
|
||||
setSelectedId: (id: number | null) => void;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => 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<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["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);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlists, selectedId, listQuery.data]);
|
||||
|
||||
const plName = (p: { kind: string; name: string }) =>
|
||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
|
||||
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: ["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);
|
||||
},
|
||||
});
|
||||
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "manage",
|
||||
title: t("playlists.manageGroup"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
{playlists.length > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => 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-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
title: t("playlists.listGroup"),
|
||||
body: (
|
||||
<>
|
||||
{playlists.length === 0 && !listQuery.isLoading && (
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||
<div className="text-xs text-muted px-1 py-1">{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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
title={t("playlists.title")}
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user