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.

View file

@ -1,25 +1,22 @@
import { useTranslation } from "react-i18next";
import { Library, Search, User, X, Youtube } from "lucide-react";
import { Search, X, Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
// Contextual top bar. Primary navigation + account now live in the left NavSidebar; the
// header carries the global sync status, the feed's scope toggle + search (or the current
// page title), the language switcher and the notification bell.
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
// The header carries just the feed search (or the current page title).
export default function Header({
me,
filters,
setFilters,
page,
onGoToFullHistory,
onYtSearch,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
page: Page;
onGoToFullHistory: () => void;
// Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota).
onYtSearch: (q: string) => void;
@ -30,37 +27,6 @@ export default function Header({
return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
{page === "feed" && (
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
</button>
))}
</div>
)}
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative">

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 &&

View file

@ -4,13 +4,17 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
GripVertical,
Library,
Pencil,
RefreshCw,
RotateCcw,
Share2,
SlidersHorizontal,
User,
X,
} from "lucide-react";
import {
@ -120,6 +124,8 @@ export default function Sidebar({
setLayout,
onFocusChannel,
isDemo = false,
collapsed,
onToggleCollapse,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
@ -127,6 +133,10 @@ export default function Sidebar({
setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean;
// Full-height collapse (state owned by App, persisted to the user's preferences), mirroring
// the left nav rail.
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -464,16 +474,81 @@ export default function Sidebar({
? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]);
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">
{t("sidebar.filters")}
// Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that
// carries the active-filter count so you can tell filters are on without opening it.
if (collapsed) {
return (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">
{t("sidebar.activeCount", { count: activeCount })}
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
}
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3">
{/* Scope: your own subscriptions (Mine) vs the shared Library — moved out of the top bar. */}
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span>{t("header.scope." + s)}</span>
</button>
))}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-sm font-semibold truncate">
{t("sidebar.filters")}
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">
{t("sidebar.activeCount", { count: activeCount })}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5">
{!editing && (

View file

@ -11,9 +11,15 @@ import Tooltip from "./Tooltip";
export default function SyncStatus({
isAdmin,
onGoToFullHistory,
variant = "bar",
collapsed = false,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
// "bar" = the legacy horizontal top-bar row. "rail" = a compact block for the top of the
// left nav sidebar (stacked; icon-only with a tooltip when the rail is collapsed).
variant?: "bar" | "rail";
collapsed?: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -51,6 +57,90 @@ export default function SyncStatus({
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
// Calm one-liner describing the current sync state; shared by the bar and rail layouts.
const stateNode = data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span>
) : syncing > 0 ? (
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
<span>{t("header.sync.allSynced")}</span>
);
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
);
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
if (variant === "rail") {
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos
)} ${t("header.sync.total")}`;
if (collapsed) {
return (
<Tooltip hint={countsText}>
<div className="relative grid place-items-center py-1 text-muted">
{active ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Database className="w-4 h-4" />
)}
{(data.paused || notFull > 0) && (
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</div>
</Tooltip>
);
}
return (
<div className="text-[11px] text-muted leading-snug space-y-1">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
{notFull > 0 && (
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
)}
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
</div>
);
}
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint={t("header.sync.countTooltip")}>

View file

@ -16,6 +16,8 @@
"hideWidget": "Widget ausblenden",
"expand": "Ausklappen",
"collapse": "Einklappen",
"collapsePanel": "Filter einklappen",
"expandPanel": "Filter ausklappen",
"any": "Beliebig",
"all": "Alle",
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",

View file

@ -16,6 +16,8 @@
"hideWidget": "Hide widget",
"expand": "Expand",
"collapse": "Collapse",
"collapsePanel": "Collapse filters",
"expandPanel": "Expand filters",
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",

View file

@ -16,6 +16,8 @@
"hideWidget": "Modul elrejtése",
"expand": "Kibontás",
"collapse": "Összecsukás",
"collapsePanel": "Szűrők összecsukása",
"expandPanel": "Szűrők kibontása",
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",

View file

@ -20,6 +20,7 @@ export const LS = {
statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
notifHistory: "siftlode.notifications",