Refine sidebar collapsed and expanded spacing

This commit is contained in:
2026-03-12 16:13:47 -04:00
parent df26299bdf
commit b39bc9eccd

View File

@@ -1,14 +1,28 @@
'use client'; "use client";
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, ChevronLeft, ChevronRight, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs, Search } from 'lucide-react'; import {
import Link from 'next/link'; Activity,
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; BarChart3,
import { useEffect, useMemo, useState } from 'react'; BookOpenText,
import { authClient } from '@/lib/auth-client'; ChartCandlestick,
import { TaskDetailModal } from '@/components/notifications/task-detail-modal'; ChevronLeft,
import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger'; 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";
import { authClient } from "@/lib/auth-client";
import { TaskDetailModal } from "@/components/notifications/task-detail-modal";
import { TaskNotificationsTrigger } from "@/components/notifications/task-notifications-trigger";
import { import {
companyAnalysisQueryOptions, companyAnalysisQueryOptions,
companyFinancialStatementsQueryOptions, companyFinancialStatementsQueryOptions,
@@ -17,13 +31,13 @@ import {
latestPortfolioInsightQueryOptions, latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions, portfolioSummaryQueryOptions,
recentTasksQueryOptions, recentTasksQueryOptions,
watchlistQueryOptions watchlistQueryOptions,
} from '@/lib/query/options'; } from "@/lib/query/options";
import { buildGraphingHref } from '@/lib/graphing/catalog'; import { buildGraphingHref } from "@/lib/graphing/catalog";
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types'; import type { ActiveContext, NavGroup, NavItem } from "@/lib/types";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center'; import { useTaskNotificationsCenter } from "@/hooks/use-task-notifications-center";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
type AppShellProps = { type AppShellProps = {
title: string; title: string;
@@ -40,104 +54,104 @@ type NavConfigItem = NavItem & {
const NAV_ITEMS: NavConfigItem[] = [ const NAV_ITEMS: NavConfigItem[] = [
{ {
id: 'home', id: "home",
href: '/', href: "/",
label: 'Home', label: "Home",
icon: Activity, icon: Activity,
group: 'overview', group: "overview",
matchMode: 'exact', matchMode: "exact",
mobilePrimary: true mobilePrimary: true,
}, },
{ {
id: 'analysis', id: "analysis",
href: '/analysis', href: "/analysis",
label: 'Analysis', label: "Analysis",
icon: LineChart, icon: LineChart,
group: 'research', group: "research",
matchMode: 'prefix', matchMode: "prefix",
preserveTicker: true, preserveTicker: true,
mobilePrimary: true mobilePrimary: true,
}, },
{ {
id: 'research', id: "research",
href: '/research', href: "/research",
label: 'Research', label: "Research",
icon: NotebookTabs, icon: NotebookTabs,
group: 'research', group: "research",
matchMode: 'exact', matchMode: "exact",
preserveTicker: true, preserveTicker: true,
mobilePrimary: true mobilePrimary: true,
}, },
{ {
id: 'graphing', id: "graphing",
href: '/graphing', href: "/graphing",
label: 'Graphing', label: "Graphing",
icon: BarChart3, icon: BarChart3,
group: 'research', group: "research",
matchMode: 'exact', matchMode: "exact",
preserveTicker: true, preserveTicker: true,
mobilePrimary: false mobilePrimary: false,
}, },
{ {
id: 'financials', id: "financials",
href: '/financials', href: "/financials",
label: 'Financials', label: "Financials",
icon: Landmark, icon: Landmark,
group: 'research', group: "research",
matchMode: 'exact', matchMode: "exact",
preserveTicker: true, preserveTicker: true,
mobilePrimary: false mobilePrimary: false,
}, },
{ {
id: 'filings', id: "filings",
href: '/filings', href: "/filings",
label: 'Filings', label: "Filings",
icon: BookOpenText, icon: BookOpenText,
group: 'research', group: "research",
matchMode: 'exact', matchMode: "exact",
preserveTicker: true, preserveTicker: true,
mobilePrimary: true mobilePrimary: true,
}, },
{ {
id: 'search', id: "search",
href: '/search', href: "/search",
label: 'Search', label: "Search",
icon: Search, icon: Search,
group: 'research', group: "research",
matchMode: 'exact', matchMode: "exact",
preserveTicker: true, preserveTicker: true,
mobilePrimary: false mobilePrimary: false,
}, },
{ {
id: 'portfolio', id: "portfolio",
href: '/portfolio', href: "/portfolio",
label: 'Portfolio', label: "Portfolio",
icon: ChartCandlestick, icon: ChartCandlestick,
group: 'portfolio', group: "portfolio",
matchMode: 'exact', matchMode: "exact",
mobilePrimary: true mobilePrimary: true,
}, },
{ {
id: 'watchlist', id: "watchlist",
href: '/watchlist', href: "/watchlist",
label: 'Coverage', label: "Coverage",
icon: Eye, icon: Eye,
group: 'portfolio', group: "portfolio",
matchMode: 'exact', matchMode: "exact",
mobilePrimary: true mobilePrimary: true,
} },
]; ];
const GROUP_LABELS: Record<NavGroup, string> = { const GROUP_LABELS: Record<NavGroup, string> = {
overview: 'Overview', overview: "Overview",
research: 'Research', research: "Research",
portfolio: 'Portfolio' portfolio: "Portfolio",
}; };
const SIDEBAR_PREFERENCE_KEY = 'fiscal-shell-sidebar-collapsed'; 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;
} }
@@ -146,12 +160,12 @@ function toTickerHref(baseHref: string, activeTicker: string | null) {
return baseHref; return baseHref;
} }
const separator = baseHref.includes('?') ? '&' : '?'; const separator = baseHref.includes("?") ? "&" : "?";
return `${baseHref}${separator}ticker=${encodeURIComponent(activeTicker)}`; return `${baseHref}${separator}ticker=${encodeURIComponent(activeTicker)}`;
} }
function resolveNavHref(item: NavItem, context: ActiveContext) { function resolveNavHref(item: NavItem, context: ActiveContext) {
if (item.href === '/graphing') { if (item.href === "/graphing") {
return buildGraphingHref(context.activeTicker); return buildGraphingHref(context.activeTicker);
} }
@@ -163,87 +177,85 @@ function resolveNavHref(item: NavItem, context: ActiveContext) {
} }
function isItemActive(item: NavItem, pathname: string) { function isItemActive(item: NavItem, pathname: string) {
if (item.matchMode === 'prefix') { if (item.matchMode === "prefix") {
return pathname === item.href || pathname.startsWith(`${item.href}/`); return pathname === item.href || pathname.startsWith(`${item.href}/`);
} }
return pathname === item.href; return pathname === item.href;
} }
function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) { function buildDefaultBreadcrumbs(
const analysisHref = toTickerHref('/analysis', activeTicker); pathname: string,
const researchHref = toTickerHref('/research', activeTicker); activeTicker: string | null,
) {
const analysisHref = toTickerHref("/analysis", activeTicker);
const researchHref = toTickerHref("/research", activeTicker);
const graphingHref = buildGraphingHref(activeTicker); const graphingHref = buildGraphingHref(activeTicker);
const financialsHref = toTickerHref('/financials', activeTicker); const financialsHref = toTickerHref("/financials", activeTicker);
const filingsHref = toTickerHref('/filings', activeTicker); const filingsHref = toTickerHref("/filings", activeTicker);
if (pathname === '/') { if (pathname === "/") {
return [{ label: 'Home' }]; return [{ label: "Home" }];
} }
if (pathname.startsWith('/analysis/reports/')) { if (pathname.startsWith("/analysis/reports/")) {
return [ return [
{ label: 'Analysis', href: analysisHref }, { label: "Analysis", href: analysisHref },
{ label: 'Reports', href: analysisHref }, { label: "Reports", href: analysisHref },
{ label: activeTicker ?? 'Summary' } { label: activeTicker ?? "Summary" },
]; ];
} }
if (pathname.startsWith('/analysis')) { if (pathname.startsWith("/analysis")) {
return [{ label: 'Analysis' }]; return [{ label: "Analysis" }];
} }
if (pathname.startsWith('/research')) { if (pathname.startsWith("/research")) {
return [ return [
{ label: 'Analysis', href: analysisHref }, { label: "Analysis", href: analysisHref },
{ label: 'Research', href: researchHref } { label: "Research", href: researchHref },
]; ];
} }
if (pathname.startsWith('/financials')) { if (pathname.startsWith("/financials")) {
return [{ label: "Analysis", href: analysisHref }, { label: "Financials" }];
}
if (pathname.startsWith("/graphing")) {
return [ return [
{ label: 'Analysis', href: analysisHref }, { label: "Analysis", href: analysisHref },
{ label: 'Financials' } { label: "Graphing", href: graphingHref },
{ label: activeTicker ?? "Compare Set" },
]; ];
} }
if (pathname.startsWith('/graphing')) { if (pathname.startsWith("/filings")) {
return [ return [{ label: "Analysis", href: analysisHref }, { label: "Filings" }];
{ label: 'Analysis', href: analysisHref },
{ label: 'Graphing', href: graphingHref },
{ label: activeTicker ?? 'Compare Set' }
];
} }
if (pathname.startsWith('/filings')) { if (pathname.startsWith("/search")) {
return [ return [{ label: "Analysis", href: analysisHref }, { label: "Search" }];
{ label: 'Analysis', href: analysisHref },
{ label: 'Filings' }
];
} }
if (pathname.startsWith('/search')) { if (pathname.startsWith("/portfolio")) {
return [ return [{ label: "Portfolio" }];
{ label: 'Analysis', href: analysisHref },
{ label: 'Search' }
];
} }
if (pathname.startsWith('/portfolio')) { if (pathname.startsWith("/watchlist")) {
return [{ label: 'Portfolio' }]; return [{ label: "Portfolio", href: "/portfolio" }, { label: "Coverage" }];
} }
if (pathname.startsWith('/watchlist')) { return [{ label: "Home", href: "/" }, { label: pathname }];
return [
{ label: 'Portfolio', href: '/portfolio' },
{ label: 'Coverage' }
];
} }
return [{ label: 'Home', href: '/' }, { label: pathname }]; export function AppShell({
} title,
subtitle,
export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs, children }: AppShellProps) { actions,
activeTicker,
breadcrumbs,
children,
}: AppShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -252,35 +264,47 @@ 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 [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [hasLoadedSidebarPreference, setHasLoadedSidebarPreference] = 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;
const role = typeof sessionUser?.role === 'string' const role =
typeof sessionUser?.role === "string"
? sessionUser.role ? sessionUser.role
: Array.isArray(sessionUser?.role) : Array.isArray(sessionUser?.role)
? sessionUser.role.filter((entry): entry is string => typeof entry === 'string').join(', ') ? sessionUser.role
.filter((entry): entry is string => typeof entry === "string")
.join(", ")
: null; : null;
const displayName = sessionUser?.name || sessionUser?.email || 'Authenticated user'; const displayName =
sessionUser?.name || sessionUser?.email || "Authenticated user";
const derivedTickerFromPath = useMemo(() => { const derivedTickerFromPath = useMemo(() => {
if (!pathname.startsWith('/analysis/reports/')) { if (!pathname.startsWith("/analysis/reports/")) {
return null; return null;
} }
const segments = pathname.split('/').filter(Boolean); const segments = pathname.split("/").filter(Boolean);
const tickerSegment = segments[2]; const tickerSegment = segments[2];
return tickerSegment ? normalizeTicker(decodeURIComponent(tickerSegment)) : null; return tickerSegment
? normalizeTicker(decodeURIComponent(tickerSegment))
: null;
}, [pathname]); }, [pathname]);
const context: ActiveContext = useMemo(() => { const context: ActiveContext = useMemo(() => {
const queryTicker = normalizeTicker(searchParams.get('ticker')); const queryTicker = normalizeTicker(searchParams.get("ticker"));
return { return {
pathname, pathname,
activeTicker: normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath activeTicker:
normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath,
}; };
}, [activeTicker, derivedTickerFromPath, pathname, searchParams]); }, [activeTicker, derivedTickerFromPath, pathname, searchParams]);
@@ -290,16 +314,16 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
return { return {
...item, ...item,
href, href,
active: isItemActive(item, pathname) active: isItemActive(item, pathname),
}; };
}); });
}, [context, pathname]); }, [context, pathname]);
const groupedNav = useMemo(() => { const groupedNav = useMemo(() => {
const groups: Array<{ group: NavGroup; items: typeof navEntries }> = [ const groups: Array<{ group: NavGroup; items: typeof navEntries }> = [
{ group: 'overview', items: [] }, { group: "overview", items: [] },
{ group: 'research', items: [] }, { group: "research", items: [] },
{ group: 'portfolio', items: [] } { group: "portfolio", items: [] },
]; ];
for (const entry of navEntries) { for (const entry of navEntries) {
@@ -331,62 +355,72 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
const prefetchForHref = (href: string) => { const prefetchForHref = (href: string) => {
router.prefetch(href); router.prefetch(href);
if (href.startsWith('/analysis')) { if (href.startsWith("/analysis")) {
if (context.activeTicker) { if (context.activeTicker) {
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker)); void queryClient.prefetchQuery(
companyAnalysisQueryOptions(context.activeTicker),
);
} }
return; return;
} }
if (href.startsWith('/financials')) { if (href.startsWith("/financials")) {
if (context.activeTicker) { if (context.activeTicker) {
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker)); void queryClient.prefetchQuery(
companyAnalysisQueryOptions(context.activeTicker),
);
} }
return; return;
} }
if (href.startsWith('/graphing')) { if (href.startsWith("/graphing")) {
if (context.activeTicker) { if (context.activeTicker) {
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({ void queryClient.prefetchQuery(
companyFinancialStatementsQueryOptions({
ticker: context.activeTicker, ticker: context.activeTicker,
surfaceKind: 'income_statement', surfaceKind: "income_statement",
cadence: 'annual', cadence: "annual",
includeDimensions: false, includeDimensions: false,
includeFacts: false, includeFacts: false,
limit: 16 limit: 16,
})); }),
);
} }
return; return;
} }
if (href.startsWith('/filings')) { if (href.startsWith("/filings")) {
void queryClient.prefetchQuery(filingsQueryOptions({ void queryClient.prefetchQuery(
filingsQueryOptions({
ticker: context.activeTicker ?? undefined, ticker: context.activeTicker ?? undefined,
limit: 120 limit: 120,
})); }),
);
return; return;
} }
if (href.startsWith('/search')) { if (href.startsWith("/search")) {
if (context.activeTicker) { if (context.activeTicker) {
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker)); void queryClient.prefetchQuery(
companyAnalysisQueryOptions(context.activeTicker),
);
} }
return; return;
} }
if (href.startsWith('/portfolio')) { if (href.startsWith("/portfolio")) {
void queryClient.prefetchQuery(holdingsQueryOptions()); void queryClient.prefetchQuery(holdingsQueryOptions());
void queryClient.prefetchQuery(portfolioSummaryQueryOptions()); void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions()); void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions());
return; return;
} }
if (href.startsWith('/watchlist')) { if (href.startsWith("/watchlist")) {
void queryClient.prefetchQuery(watchlistQueryOptions()); void queryClient.prefetchQuery(watchlistQueryOptions());
return; return;
} }
if (href === '/') { if (href === "/") {
void queryClient.prefetchQuery(portfolioSummaryQueryOptions()); void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
void queryClient.prefetchQuery(filingsQueryOptions({ limit: 200 })); void queryClient.prefetchQuery(filingsQueryOptions({ limit: 200 }));
void queryClient.prefetchQuery(watchlistQueryOptions()); void queryClient.prefetchQuery(watchlistQueryOptions());
@@ -396,8 +430,10 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
}; };
useEffect(() => { useEffect(() => {
const storedPreference = window.localStorage.getItem(SIDEBAR_PREFERENCE_KEY); const storedPreference = window.localStorage.getItem(
if (storedPreference === 'true') { SIDEBAR_PREFERENCE_KEY,
);
if (storedPreference === "true") {
setIsSidebarCollapsed(true); setIsSidebarCollapsed(true);
} }
setHasLoadedSidebarPreference(true); setHasLoadedSidebarPreference(true);
@@ -408,7 +444,10 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
return; return;
} }
window.localStorage.setItem(SIDEBAR_PREFERENCE_KEY, String(isSidebarCollapsed)); window.localStorage.setItem(
SIDEBAR_PREFERENCE_KEY,
String(isSidebarCollapsed),
);
}, [hasLoadedSidebarPreference, isSidebarCollapsed]); }, [hasLoadedSidebarPreference, isSidebarCollapsed]);
useEffect(() => { useEffect(() => {
@@ -418,7 +457,13 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
}; };
const runPrefetch = () => { const runPrefetch = () => {
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'graphing' || entry.id === 'filings' || entry.id === 'portfolio'); const prioritized = navEntries.filter(
(entry) =>
entry.id === "analysis" ||
entry.id === "graphing" ||
entry.id === "filings" ||
entry.id === "portfolio",
);
for (const entry of prioritized) { for (const entry of prioritized) {
prefetchForHref(entry.href); prefetchForHref(entry.href);
} }
@@ -456,7 +501,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
setIsSigningOut(true); setIsSigningOut(true);
try { try {
await authClient.signOut(); await authClient.signOut();
router.replace('/auth/signin'); router.replace("/auth/signin");
} finally { } finally {
setIsSigningOut(false); setIsSigningOut(false);
} }
@@ -467,18 +512,32 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
<div className="ambient-grid" aria-hidden="true" /> <div className="ambient-grid" aria-hidden="true" />
<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
<aside
className={cn( className={cn(
'hidden shrink-0 flex-col gap-6 border-r border-[color:var(--line-weak)] transition-[width,padding] duration-200 lg:flex', "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",
isSidebarCollapsed ? 'w-20 pr-3' : 'w-72 pr-6' isSidebarCollapsed ? "lg:pl-3" : "lg:pl-6",
)}
>
<aside
className={cn(
"hidden shrink-0 flex-col gap-4 border-r border-[color:var(--line-weak)] transition-[width,padding] duration-200 lg:flex",
isSidebarCollapsed ? "w-16 pr-1" : "w-72 pr-4",
)}
>
<div
className={cn(
"flex items-start gap-3",
isSidebarCollapsed ? "justify-center" : "justify-between",
)} )}
> >
<div className={cn('flex items-start gap-3', isSidebarCollapsed ? 'justify-center' : 'justify-between')}>
{!isSidebarCollapsed ? ( {!isSidebarCollapsed ? (
<div className="min-w-0"> <div className="min-w-0">
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p> <p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1> 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)]"> <p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
Financial intelligence cockpit with durable AI workflows. Financial intelligence cockpit with durable AI workflows.
</p> </p>
@@ -489,22 +548,38 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setIsSidebarCollapsed((prev) => !prev)} onClick={() => setIsSidebarCollapsed((prev) => !prev)}
className={cn('shrink-0', isSidebarCollapsed ? 'min-h-11 w-11 px-0' : 'min-h-11 w-11 px-0')} className={cn(
aria-label={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} "shrink-0",
isSidebarCollapsed
? "min-h-10 w-10 px-0"
: "min-h-11 w-11 px-0",
)}
aria-label={
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
aria-pressed={isSidebarCollapsed} aria-pressed={isSidebarCollapsed}
title={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
> >
{isSidebarCollapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />} {isSidebarCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button> </Button>
</div> </div>
<nav className="space-y-4" aria-label="Primary"> <nav className="space-y-3" aria-label="Primary">
{groupedNav.map(({ group, items }) => ( {groupedNav.map(({ group, items }) => (
<div key={group} className="space-y-2"> <div key={group} className="space-y-1.5">
{isSidebarCollapsed ? ( {isSidebarCollapsed ? (
<div className="mx-auto h-px w-8 bg-[color:var(--line-weak)]" aria-hidden="true" /> <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> <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;
@@ -513,17 +588,19 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
<Link <Link
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} 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} title={item.label}
className={cn( className={cn(
'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', "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', isSidebarCollapsed
? "justify-center px-0 py-2.5"
: "gap-3 px-2.5 py-1.5",
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" />
@@ -535,38 +612,37 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
))} ))}
</nav> </nav>
<div {!isSidebarCollapsed ? (
className={cn( <>
'mt-auto rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)]',
isSidebarCollapsed ? 'p-2' : 'p-3'
)}
>
{isSidebarCollapsed ? (
<Button <Button
className="w-full min-h-12 px-0" className="w-full"
variant="ghost" variant="ghost"
onClick={() => void signOut()} onClick={() => void signOut()}
disabled={isSigningOut} disabled={isSigningOut}
aria-label={isSigningOut ? 'Signing out' : 'Sign out'}
title={displayName}
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
{isSigningOut ? "Signing out..." : "Sign out"}
</Button> </Button>
) : (
<> <div className="rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p> <p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p> Runtime
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null} </p>
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]"> <p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">
AI and market data are driven by environment configuration and live API tasks. {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> </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>
</>
) : null}
</aside> </aside>
<div className="min-w-0 flex-1 pb-24 lg:pb-0"> <div className="min-w-0 flex-1 pb-24 lg:pb-0">
@@ -590,17 +666,28 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
</div> </div>
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="min-w-0 pr-6 sm:pr-0"> <div className="min-w-0 pr-6 sm:pr-0">
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Live System</p> <p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">
<h2 className="mt-2 text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl md:text-3xl">{title}</h2> Live System
</p>
<h2 className="mt-2 text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl md:text-3xl">
{title}
</h2>
{subtitle ? ( {subtitle ? (
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p> <p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
{subtitle}
</p>
) : null} ) : null}
</div> </div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
{actions} {actions}
<Button variant="ghost" className="max-sm:hidden sm:inline-flex lg:hidden" onClick={() => void signOut()} disabled={isSigningOut}> <Button
variant="ghost"
className="max-sm:hidden sm:inline-flex lg:hidden"
onClick={() => void signOut()}
disabled={isSigningOut}
>
<LogOut className="size-4" /> <LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'} {isSigningOut ? "Signing out..." : "Sign out"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -615,18 +702,28 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
const isLast = index === breadcrumbItems.length - 1; const isLast = index === breadcrumbItems.length - 1;
return ( return (
<li key={`${item.label}-${index}`} className="flex items-center gap-2"> <li
key={`${item.label}-${index}`}
className="flex items-center gap-2"
>
{item.href && !isLast ? ( {item.href && !isLast ? (
<Link <Link
href={item.href} href={item.href}
onMouseEnter={() => prefetchForHref(item.href as string)} onMouseEnter={() =>
prefetchForHref(item.href as string)
}
onFocus={() => prefetchForHref(item.href as string)} onFocus={() => prefetchForHref(item.href as string)}
className="rounded px-1 py-0.5 text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]" className="rounded px-1 py-0.5 text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
> >
{item.label} {item.label}
</Link> </Link>
) : ( ) : (
<span className={cn(isLast ? 'text-[color:var(--terminal-bright)]' : '')} aria-current={isLast ? 'page' : undefined}> <span
className={cn(
isLast ? "text-[color:var(--terminal-bright)]" : "",
)}
aria-current={isLast ? "page" : undefined}
>
{item.label} {item.label}
</span> </span>
)} )}
@@ -645,7 +742,10 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:rgba(24,27,32,0.96)] px-2 py-2 backdrop-blur lg:hidden" className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:rgba(24,27,32,0.96)] px-2 py-2 backdrop-blur lg:hidden"
aria-label="Mobile primary" aria-label="Mobile primary"
> >
<div className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-1 px-1" style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) * 0.5)' }}> <div
className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-1 px-1"
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) * 0.5)" }}
>
{mobilePrimaryEntries.map((item) => { {mobilePrimaryEntries.map((item) => {
const Icon = item.icon; const Icon = item.icon;
@@ -653,14 +753,14 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
aria-current={item.active ? 'page' : undefined} aria-current={item.active ? "page" : undefined}
onMouseEnter={() => prefetchForHref(item.href)} onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)} onFocus={() => prefetchForHref(item.href)}
className={cn( className={cn(
'flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]', "flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
item.active item.active
? 'bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]' ? "bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
: 'text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]' : "text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
)} )}
> >
<Icon className="size-4" /> <Icon className="size-4" />
@@ -683,15 +783,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
</nav> </nav>
{isMoreOpen ? ( {isMoreOpen ? (
<div className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden" onClick={() => setIsMoreOpen(false)}> <div
className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden"
onClick={() => setIsMoreOpen(false)}
>
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="absolute inset-x-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3 shadow-[0_20px_60px_rgba(0,0,0,0.45)]" className="absolute inset-x-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3 shadow-[0_20px_60px_rgba(0,0,0,0.45)]"
style={{ bottom: 'calc(4.7rem + env(safe-area-inset-bottom))' }} style={{ bottom: "calc(4.7rem + env(safe-area-inset-bottom))" }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<p className="terminal-caption mb-2 px-1 text-[11px] uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">More destinations</p> <p className="terminal-caption mb-2 px-1 text-[11px] uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
More destinations
</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{mobileMoreEntries.map((item) => { {mobileMoreEntries.map((item) => {
const Icon = item.icon; const Icon = item.icon;
@@ -699,15 +804,15 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
aria-current={item.active ? 'page' : undefined} aria-current={item.active ? "page" : undefined}
onMouseEnter={() => prefetchForHref(item.href)} onMouseEnter={() => prefetchForHref(item.href)}
onFocus={() => prefetchForHref(item.href)} onFocus={() => prefetchForHref(item.href)}
onClick={() => setIsMoreOpen(false)} onClick={() => setIsMoreOpen(false)}
className={cn( className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]', "flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
item.active item.active
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]' ? "border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
: 'border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]' : "border-transparent 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" />
@@ -724,7 +829,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
className="col-span-2 flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]" className="col-span-2 flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'} {isSigningOut ? "Signing out..." : "Sign out"}
</button> </button>
</div> </div>
</div> </div>