feat(shell): add collapsible sidebar rail

This commit is contained in:
2026-03-12 15:39:44 -04:00
parent 33ce48f53c
commit 1b545cfffd

View File

@@ -2,7 +2,7 @@
import { useQueryClient } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
import { Activity, BarChart3, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs, Search } from 'lucide-react';
import { Activity, BarChart3, BookOpenText, ChartCandlestick, ChevronLeft, ChevronRight, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs, Search } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
@@ -134,6 +134,8 @@ const GROUP_LABELS: Record<NavGroup, string> = {
portfolio: 'Portfolio'
};
const SIDEBAR_PREFERENCE_KEY = 'fiscal-shell-sidebar-collapsed';
function normalizeTicker(value: string | null | undefined) {
const normalized = value?.trim().toUpperCase() ?? '';
return normalized.length > 0 ? normalized : null;
@@ -249,6 +251,8 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
const [isSigningOut, setIsSigningOut] = useState(false);
const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [hasLoadedSidebarPreference, setHasLoadedSidebarPreference] = useState(false);
const notifications = useTaskNotificationsCenter();
const { data: session } = authClient.useSession();
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
@@ -391,6 +395,22 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
}
};
useEffect(() => {
const storedPreference = window.localStorage.getItem(SIDEBAR_PREFERENCE_KEY);
if (storedPreference === 'true') {
setIsSidebarCollapsed(true);
}
setHasLoadedSidebarPreference(true);
}, []);
useEffect(() => {
if (!hasLoadedSidebarPreference) {
return;
}
window.localStorage.setItem(SIDEBAR_PREFERENCE_KEY, String(isSidebarCollapsed));
}, [hasLoadedSidebarPreference, isSidebarCollapsed]);
useEffect(() => {
const browserWindow = window as Window & {
requestIdleCallback?: (callback: IdleRequestCallback) => number;
@@ -448,19 +468,54 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
<div className="noise-layer" aria-hidden="true" />
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1440px] gap-6 px-4 pb-10 pt-4 sm:px-5 sm:pb-12 sm:pt-6 md:px-8 lg:gap-8">
<aside className="hidden w-72 shrink-0 flex-col gap-6 border-r border-[color:var(--line-weak)] pr-6 lg:flex">
<div>
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
Financial intelligence cockpit with durable AI workflows.
</p>
<aside
className={cn(
'hidden shrink-0 flex-col gap-6 border-r border-[color:var(--line-weak)] transition-[width,padding] duration-200 lg:flex',
isSidebarCollapsed ? 'w-20 pr-3' : 'w-72 pr-6'
)}
>
<div className={cn('flex items-start gap-3', isSidebarCollapsed ? 'justify-center' : 'justify-between')}>
<div className={cn('min-w-0', isSidebarCollapsed && 'flex items-center justify-center')}>
{isSidebarCollapsed ? (
<div
className="flex size-11 items-center justify-center rounded-2xl border border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-sm font-semibold tracking-[0.18em] text-[color:var(--terminal-bright)]"
aria-label="Fiscal Clone"
title="Fiscal Clone"
>
FC
</div>
) : (
<>
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
Financial intelligence cockpit with durable AI workflows.
</p>
</>
)}
</div>
<Button
type="button"
variant="ghost"
onClick={() => setIsSidebarCollapsed((prev) => !prev)}
className={cn('shrink-0', isSidebarCollapsed ? 'min-h-11 w-11 px-0' : 'min-h-11 w-11 px-0')}
aria-label={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={isSidebarCollapsed}
title={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isSidebarCollapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
</Button>
</div>
<nav className="space-y-4" aria-label="Primary">
{groupedNav.map(({ group, items }) => (
<div key={group} className="space-y-2">
<p className="terminal-caption px-2 text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{GROUP_LABELS[group]}</p>
{isSidebarCollapsed ? (
<div className="mx-auto h-px w-8 bg-[color:var(--line-weak)]" aria-hidden="true" />
) : (
<p className="terminal-caption px-2 text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{GROUP_LABELS[group]}</p>
)}
{items.map((item) => {
const Icon = item.icon;
@@ -469,17 +524,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
key={item.id}
href={item.href}
aria-current={item.active ? 'page' : undefined}
aria-label={isSidebarCollapsed ? item.label : undefined}
onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)}
title={item.label}
className={cn(
'flex items-center gap-3 rounded-xl border border-transparent px-3 py-2 text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
'flex items-center rounded-xl border border-transparent text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
isSidebarCollapsed ? 'justify-center px-0 py-3' : 'gap-3 px-3 py-2',
item.active
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[inset_2px_0_0_var(--accent)]'
: 'text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]'
)}
>
<Icon className="size-4" />
{item.label}
{!isSidebarCollapsed ? <span>{item.label}</span> : null}
</Link>
);
})}
@@ -487,17 +545,37 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
))}
</nav>
<div className="mt-auto rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
AI and market data are driven by environment configuration and live API tasks.
</p>
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'}
</Button>
<div
className={cn(
'mt-auto rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)]',
isSidebarCollapsed ? 'p-2' : 'p-3'
)}
>
{isSidebarCollapsed ? (
<Button
className="w-full min-h-12 px-0"
variant="ghost"
onClick={() => void signOut()}
disabled={isSigningOut}
aria-label={isSigningOut ? 'Signing out' : 'Sign out'}
title={displayName}
>
<LogOut className="size-4" />
</Button>
) : (
<>
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
AI and market data are driven by environment configuration and live API tasks.
</p>
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'}
</Button>
</>
)}
</div>
</aside>