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

@ -23,11 +23,11 @@ import {
import { api, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import { LS } from "../lib/storage";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import SyncStatus from "./SyncStatus";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -39,6 +39,9 @@ export default function NavSidebar({
onOpenAbout,
onChangeLanguage,
language,
collapsed,
onToggleCollapse,
onGoToFullHistory,
}: {
me: Me;
page: Page;
@ -46,11 +49,13 @@ export default function NavSidebar({
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
language: LangCode;
// Collapse state is owned by App (persisted to the user's preferences); the rail just reflects
// it and asks App to flip it.
collapsed: boolean;
onToggleCollapse: () => void;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
() => localStorage.getItem(LS.navCollapsed) === "1"
);
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null);
@ -88,14 +93,6 @@ export default function NavSidebar({
};
}, [acctOpen]);
function toggleCollapsed() {
setCollapsed((c) => {
const next = !c;
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
return next;
});
}
async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" });
location.reload();
@ -247,7 +244,7 @@ export default function NavSidebar({
</button>
)}
<button
onClick={toggleCollapsed}
onClick={onToggleCollapse}
title={collapsed ? t("nav.expand") : t("nav.collapse")}
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
@ -260,6 +257,16 @@ export default function NavSidebar({
</button>
</div>
{/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */}
<div className="mb-3 pb-3 border-b border-border">
<SyncStatus
isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory}
variant="rail"
collapsed={collapsed}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{userItems.map(renderItem)}
{systemItems.length > 0 &&