feat(layout): full-height collapsible filter sidebar; sync status + scope relocated

Restructure the app shell into three top-level columns:
- The per-user sync status (video counts + live sync state) moves from the top bar to a
  compact block at the top of the left nav rail (icon-only with a tooltip when collapsed).
- The feed's Mine/Library scope toggle moves to the top of the filter sidebar.
- The filter sidebar becomes a full-height sibling column with its own collapse control
  (a thin rail carrying the active-filter count), mirroring the nav rail. The top bar is
  now just the feed search / page title.
- Both panels' collapsed state is persisted to the user's preferences (server-side, so it
  follows the account across devices), seeded from a localStorage cache to avoid a flash.

Default: both panels open.
This commit is contained in:
npeter83 2026-07-01 23:08:25 +02:00
parent c6fe94450b
commit 072b3296a3
9 changed files with 255 additions and 78 deletions

View file

@ -19,7 +19,7 @@ import {
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage";
import { LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome";
@ -257,6 +257,26 @@ export default function App() {
api.savePrefs({ sidebarLayout: next }).catch(() => {});
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.navCollapsed, false))
);
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.filterCollapsed, false))
);
function setNavCollapsed(next: boolean) {
setNavCollapsedState(next);
writeJSON(LS.navCollapsed, next);
api.savePrefs({ navCollapsed: next }).catch(() => {});
}
function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next);
writeJSON(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {});
}
useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
@ -423,6 +443,14 @@ export default function App() {
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
if (typeof prefs.navCollapsed === "boolean") {
setNavCollapsedState(prefs.navCollapsed);
writeJSON(LS.navCollapsed, prefs.navCollapsed);
}
if (typeof prefs.filterCollapsed === "boolean") {
setFilterCollapsedState(prefs.filterCollapsed);
writeJSON(LS.filterCollapsed, prefs.filterCollapsed);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
// The demo account is shared: there are no subscriptions, so default it to the whole
// library (the "my" feed would be empty). The communal-state warning is a permanent
@ -564,7 +592,29 @@ export default function App() {
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
collapsed={navCollapsed}
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
content column. Sits beside the nav rail as its own collapsible column. */}
{page === "feed" && !channelView && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<ChannelPage
@ -584,26 +634,9 @@ export default function App() {
setFilters={setFilters}
page={page}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "channels" ? (
<Channels
@ -661,7 +694,6 @@ export default function App() {
/>
)}
</main>
</div>
</>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.