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:
parent
7f358f63e3
commit
d92e487cf4
18 changed files with 1111 additions and 872 deletions
73
frontend/src/lib/panelLayout.ts
Normal file
73
frontend/src/lib/panelLayout.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
39
frontend/src/lib/useScrollFade.ts
Normal file
39
frontend/src/lib/useScrollFade.ts
Normal 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 } };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue