feat(shell): add collapsible sidebar rail
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user