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

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