feat(shell): add collapsible sidebar rail
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
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 Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -134,6 +134,8 @@ const GROUP_LABELS: Record<NavGroup, string> = {
|
|||||||
portfolio: 'Portfolio'
|
portfolio: 'Portfolio'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_PREFERENCE_KEY = 'fiscal-shell-sidebar-collapsed';
|
||||||
|
|
||||||
function normalizeTicker(value: string | null | undefined) {
|
function normalizeTicker(value: string | null | undefined) {
|
||||||
const normalized = value?.trim().toUpperCase() ?? '';
|
const normalized = value?.trim().toUpperCase() ?? '';
|
||||||
return normalized.length > 0 ? normalized : null;
|
return normalized.length > 0 ? normalized : null;
|
||||||
@@ -249,6 +251,8 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
|
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
const [hasLoadedSidebarPreference, setHasLoadedSidebarPreference] = useState(false);
|
||||||
const notifications = useTaskNotificationsCenter();
|
const notifications = useTaskNotificationsCenter();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
|
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(() => {
|
useEffect(() => {
|
||||||
const browserWindow = window as Window & {
|
const browserWindow = window as Window & {
|
||||||
requestIdleCallback?: (callback: IdleRequestCallback) => number;
|
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="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">
|
<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">
|
<aside
|
||||||
<div>
|
className={cn(
|
||||||
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
'hidden shrink-0 flex-col gap-6 border-r border-[color:var(--line-weak)] transition-[width,padding] duration-200 lg:flex',
|
||||||
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1>
|
isSidebarCollapsed ? 'w-20 pr-3' : 'w-72 pr-6'
|
||||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
)}
|
||||||
Financial intelligence cockpit with durable AI workflows.
|
>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-4" aria-label="Primary">
|
<nav className="space-y-4" aria-label="Primary">
|
||||||
{groupedNav.map(({ group, items }) => (
|
{groupedNav.map(({ group, items }) => (
|
||||||
<div key={group} className="space-y-2">
|
<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) => {
|
{items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
@@ -469,17 +524,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={item.active ? 'page' : undefined}
|
aria-current={item.active ? 'page' : undefined}
|
||||||
|
aria-label={isSidebarCollapsed ? item.label : undefined}
|
||||||
onMouseEnter={() => prefetchForHref(item.href)}
|
onMouseEnter={() => prefetchForHref(item.href)}
|
||||||
onFocus={() => prefetchForHref(item.href)}
|
onFocus={() => prefetchForHref(item.href)}
|
||||||
|
title={item.label}
|
||||||
className={cn(
|
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
|
item.active
|
||||||
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[inset_2px_0_0_var(--accent)]'
|
? '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)]'
|
: '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" />
|
<Icon className="size-4" />
|
||||||
{item.label}
|
{!isSidebarCollapsed ? <span>{item.label}</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -487,17 +545,37 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
<div
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
|
className={cn(
|
||||||
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
|
'mt-auto rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)]',
|
||||||
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
|
isSidebarCollapsed ? 'p-2' : 'p-3'
|
||||||
<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>
|
{isSidebarCollapsed ? (
|
||||||
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
<Button
|
||||||
<LogOut className="size-4" />
|
className="w-full min-h-12 px-0"
|
||||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
variant="ghost"
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user