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

@ -0,0 +1,73 @@
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
// which are hidden — the same "customize" affordance the feed filter rail had, generalized so
// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
import { LS, readAccount, writeAccount } from "./storage";
export type PanelId = "feed" | "plex" | "playlists";
export interface PanelLayout {
order: string[];
collapsed: Record<string, boolean>;
hidden: Record<string, boolean>;
}
// The default group order per panel. Group ids must match the ids each panel feeds to PanelGroups.
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["savedviews", "date", "tags", "language", "topic"],
plex: [
"scope",
"playlists",
"watch",
"sort",
"people",
"collection",
"rating",
"genre",
"year",
"duration",
"added",
"contentrating",
],
playlists: ["manage", "list"],
};
// localStorage key per panel (feed keeps its original key for backward compatibility).
const LS_KEY: Record<PanelId, string> = {
feed: LS.sidebarLayout,
plex: LS.plexLayout,
playlists: LS.playlistsLayout,
};
// Key on the server `preferences` blob per panel (feed keeps its original key).
export const PREF_KEY: Record<PanelId, "sidebarLayout" | "plexLayout" | "playlistsLayout"> = {
feed: "sidebarLayout",
plex: "plexLayout",
playlists: "playlistsLayout",
};
export function defaultLayout(panel: PanelId): PanelLayout {
return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} };
}
export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
const all = PANEL_ORDER[panel];
const r = (raw ?? {}) as Partial<PanelLayout>;
const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : [];
for (const id of all) if (!order.includes(id)) order.push(id);
return {
order,
collapsed: { ...(r.collapsed ?? {}) },
hidden: { ...(r.hidden ?? {}) },
};
}
export function loadLayout(panel: PanelId): PanelLayout {
return normalizeLayout(panel, readAccount<unknown>(LS_KEY[panel], {}));
}
export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void {
writeAccount(LS_KEY[panel], l);
}

View file

@ -1,46 +0,0 @@
// Per-user customization of the filter sidebar: order of the widget cards, which are
// collapsed, and which are hidden. Persisted to localStorage and the server-side
// `preferences` blob so it follows the account.
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"];
export interface SidebarLayout {
order: WidgetId[];
collapsed: Partial<Record<WidgetId, boolean>>;
hidden: Partial<Record<WidgetId, boolean>>;
}
export const DEFAULT_LAYOUT: SidebarLayout = {
order: [...ALL_WIDGETS],
collapsed: {},
hidden: {},
};
// Tolerate stale/partial data: keep only known widgets and append any that are missing
// (e.g. a widget added in a later version) so nothing silently disappears.
export function normalizeLayout(raw: unknown): SidebarLayout {
const r = (raw ?? {}) as Partial<SidebarLayout>;
const order: WidgetId[] = Array.isArray(r.order)
? (r.order.filter((x) => ALL_WIDGETS.includes(x as WidgetId)) as WidgetId[])
: [];
for (const w of ALL_WIDGETS) if (!order.includes(w)) order.push(w);
return {
order,
collapsed: { ...(r.collapsed ?? {}) },
hidden: { ...(r.hidden ?? {}) },
};
}
export function loadLayout(): SidebarLayout {
return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
}
export function saveLayoutLocal(l: SidebarLayout): void {
writeAccount(LS.sidebarLayout, l);
}

View file

@ -6,6 +6,9 @@ import { useCallback, useState } from "react";
export const LS = {
theme: "siftlode.theme",
sidebarLayout: "siftlode.sidebarLayout",
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",

View file

@ -0,0 +1,39 @@
import { useEffect, useState, type CSSProperties } from "react";
// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
export function useScrollFade(fadePx = 20): {
ref: (node: HTMLDivElement | null) => void;
style: CSSProperties;
} {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const [fade, setFade] = useState({ up: false, down: false });
useEffect(() => {
if (!el) return;
const update = () =>
setFade({
up: el.scrollTop > 1,
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
});
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
if (el.firstElementChild) ro.observe(el.firstElementChild);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, [el]);
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
fade.down ? fadePx : 0
}px), transparent)`;
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
}