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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user