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:
2026-07-13 05:13:40 +02:00
parent c2d914bacb
commit facb3ac9cb
18 changed files with 1111 additions and 872 deletions
+73 -19
View File
@@ -23,9 +23,11 @@ import { pageTitleKey } from "./lib/pageMeta";
import {
loadLayout,
normalizeLayout,
PREF_KEY,
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
type PanelId,
type PanelLayout,
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import {
@@ -49,6 +51,7 @@ import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
@@ -180,7 +183,13 @@ export default function App() {
}));
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
feed: loadLayout("feed"),
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
const [page, setPageState] = useState<Page>(loadInitialPage);
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
// SearchBar can drive them (the modules read/seed them via props).
@@ -351,10 +360,10 @@ export default function App() {
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
function setSidebarLayout(next: SidebarLayout) {
setSidebarLayoutState(next);
saveLayoutLocal(next);
api.savePrefs({ sidebarLayout: next }).catch(() => {});
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
@@ -366,6 +375,10 @@ export default function App() {
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false))
);
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.playlistsCollapsed, false))
);
function setNavCollapsed(next: boolean) {
setNavCollapsedState(next);
writeAccount(LS.navCollapsed, next);
@@ -376,6 +389,21 @@ export default function App() {
writeAccount(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {});
}
function setPlaylistsCollapsed(next: boolean) {
setPlaylistsCollapsedState(next);
writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
}
// The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane
// (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL).
const [selectedPlaylistId, setSelectedPlaylistIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSelectedPlaylistId = (id: number | null) => {
setSelectedPlaylistIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
};
useEffect(() => applyTheme(theme), [theme]);
@@ -590,11 +618,15 @@ export default function App() {
setNotif(adopted.notifications);
setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page).
if (prefs.sidebarLayout) {
const l = normalizeLayout(prefs.sidebarLayout);
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
const prefsRec = prefs as Record<string, unknown>;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
if (raw) {
const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
saveLayoutLocal(p, l);
}
});
if (typeof prefs.navCollapsed === "boolean") {
setNavCollapsedState(prefs.navCollapsed);
writeAccount(LS.navCollapsed, prefs.navCollapsed);
@@ -603,6 +635,10 @@ export default function App() {
setFilterCollapsedState(prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
}
if (typeof prefsRec.playlistsCollapsed === "boolean") {
setPlaylistsCollapsedState(prefsRec.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// dedicated effect above, which also covers accounts that have no stored preferences.)
@@ -761,8 +797,8 @@ export default function App() {
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
layout={panelLayouts.feed}
setLayout={(l) => setPanelLayout("feed", l)}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
@@ -781,11 +817,25 @@ export default function App() {
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
</Suspense>
)}
{page === "playlists" && !channelView && (
<PlaylistsRail
canWrite={meQuery.data!.can_write}
search={playlistsSearch}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed}
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<Suspense fallback={pageFallback}>
@@ -815,11 +865,11 @@ export default function App() {
setPage={setPage}
onYtSearch={enterYtSearch}
/>
{/* The header floats (fixed) over the content, so it no longer occupies flow space. This
wrapper clears it with a top pad — EXCEPT on Playlists, whose own left rail must reach
the very top (it pads only its content pane internally instead). */}
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level
floating panels beside the nav — none sit inside this content column anymore.) */}
<div
className={`flex-1 min-w-0 min-h-0 flex flex-col ${page === "playlists" ? "" : "pt-[var(--hdr-h)]"}`}
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
@@ -858,7 +908,11 @@ export default function App() {
) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog />
) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} search={playlistsSearch} />
<Playlists
canWrite={meQuery.data!.can_write}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
/>
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? (