upgrade navigation and route prefetch responsiveness
This commit is contained in:
25
components/providers/query-provider.tsx
Normal file
25
components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
type QueryProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut } from 'lucide-react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
filingsQueryOptions,
|
||||
holdingsQueryOptions,
|
||||
latestPortfolioInsightQueryOptions,
|
||||
portfolioSummaryQueryOptions,
|
||||
recentTasksQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,22 +24,168 @@ type AppShellProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
activeTicker?: string | null;
|
||||
breadcrumbs?: Array<{ label: string; href?: string }>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: 'Command Center', icon: Activity },
|
||||
{ href: '/analysis', label: 'Company Analysis', icon: LineChart },
|
||||
{ href: '/financials', label: 'Financials', icon: Landmark },
|
||||
{ href: '/filings', label: 'Filings Stream', icon: BookOpenText },
|
||||
{ href: '/portfolio', label: 'Portfolio Matrix', icon: ChartCandlestick },
|
||||
{ href: '/watchlist', label: 'Watchlist', icon: Eye }
|
||||
type NavConfigItem = NavItem & {
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavConfigItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
icon: Activity,
|
||||
group: 'overview',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
href: '/analysis',
|
||||
label: 'Analysis',
|
||||
icon: LineChart,
|
||||
group: 'research',
|
||||
matchMode: 'prefix',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'financials',
|
||||
href: '/financials',
|
||||
label: 'Financials',
|
||||
icon: Landmark,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false
|
||||
},
|
||||
{
|
||||
id: 'filings',
|
||||
href: '/filings',
|
||||
label: 'Filings',
|
||||
icon: BookOpenText,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
href: '/portfolio',
|
||||
label: 'Portfolio',
|
||||
icon: ChartCandlestick,
|
||||
group: 'portfolio',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'watchlist',
|
||||
href: '/watchlist',
|
||||
label: 'Watchlist',
|
||||
icon: Eye,
|
||||
group: 'portfolio',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
}
|
||||
];
|
||||
|
||||
export function AppShell({ title, subtitle, actions, children }: AppShellProps) {
|
||||
const GROUP_LABELS: Record<NavGroup, string> = {
|
||||
overview: 'Overview',
|
||||
research: 'Research',
|
||||
portfolio: 'Portfolio'
|
||||
};
|
||||
|
||||
function normalizeTicker(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function toTickerHref(baseHref: string, activeTicker: string | null) {
|
||||
if (!activeTicker) {
|
||||
return baseHref;
|
||||
}
|
||||
|
||||
const separator = baseHref.includes('?') ? '&' : '?';
|
||||
return `${baseHref}${separator}ticker=${encodeURIComponent(activeTicker)}`;
|
||||
}
|
||||
|
||||
function resolveNavHref(item: NavItem, context: ActiveContext) {
|
||||
if (!item.preserveTicker) {
|
||||
return item.href;
|
||||
}
|
||||
|
||||
return toTickerHref(item.href, context.activeTicker);
|
||||
}
|
||||
|
||||
function isItemActive(item: NavItem, pathname: string) {
|
||||
if (item.matchMode === 'prefix') {
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return pathname === item.href;
|
||||
}
|
||||
|
||||
function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) {
|
||||
const analysisHref = toTickerHref('/analysis', activeTicker);
|
||||
const financialsHref = toTickerHref('/financials', activeTicker);
|
||||
const filingsHref = toTickerHref('/filings', activeTicker);
|
||||
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Home' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/analysis/reports/')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Reports', href: analysisHref },
|
||||
{ label: activeTicker ?? 'Summary' }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/analysis')) {
|
||||
return [{ label: 'Analysis' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/financials')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Financials' }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/filings')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Filings' }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/portfolio')) {
|
||||
return [{ label: 'Portfolio' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/watchlist')) {
|
||||
return [
|
||||
{ label: 'Portfolio', href: '/portfolio' },
|
||||
{ label: 'Watchlist' }
|
||||
];
|
||||
}
|
||||
|
||||
return [{ label: 'Home', href: '/' }, { label: pathname }];
|
||||
}
|
||||
|
||||
export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs, children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
|
||||
|
||||
@@ -36,8 +194,150 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
||||
: Array.isArray(sessionUser?.role)
|
||||
? sessionUser.role.filter((entry): entry is string => typeof entry === 'string').join(', ')
|
||||
: null;
|
||||
|
||||
const displayName = sessionUser?.name || sessionUser?.email || 'Authenticated user';
|
||||
|
||||
const derivedTickerFromPath = useMemo(() => {
|
||||
if (!pathname.startsWith('/analysis/reports/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const tickerSegment = segments[2];
|
||||
return tickerSegment ? normalizeTicker(decodeURIComponent(tickerSegment)) : null;
|
||||
}, [pathname]);
|
||||
|
||||
const context: ActiveContext = useMemo(() => {
|
||||
const queryTicker = normalizeTicker(searchParams.get('ticker'));
|
||||
|
||||
return {
|
||||
pathname,
|
||||
activeTicker: normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath
|
||||
};
|
||||
}, [activeTicker, derivedTickerFromPath, pathname, searchParams]);
|
||||
|
||||
const navEntries = useMemo(() => {
|
||||
return NAV_ITEMS.map((item) => {
|
||||
const href = resolveNavHref(item, context);
|
||||
return {
|
||||
...item,
|
||||
href,
|
||||
active: isItemActive(item, pathname)
|
||||
};
|
||||
});
|
||||
}, [context, pathname]);
|
||||
|
||||
const groupedNav = useMemo(() => {
|
||||
const groups: Array<{ group: NavGroup; items: typeof navEntries }> = [
|
||||
{ group: 'overview', items: [] },
|
||||
{ group: 'research', items: [] },
|
||||
{ group: 'portfolio', items: [] }
|
||||
];
|
||||
|
||||
for (const entry of navEntries) {
|
||||
const group = groups.find((candidate) => candidate.group === entry.group);
|
||||
if (group) {
|
||||
group.items.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [navEntries]);
|
||||
|
||||
const mobilePrimaryEntries = useMemo(() => {
|
||||
return navEntries.filter((entry) => entry.mobilePrimary);
|
||||
}, [navEntries]);
|
||||
|
||||
const mobileMoreEntries = useMemo(() => {
|
||||
return navEntries.filter((entry) => !entry.mobilePrimary);
|
||||
}, [navEntries]);
|
||||
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
if (breadcrumbs && breadcrumbs.length > 0) {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
return buildDefaultBreadcrumbs(pathname, context.activeTicker);
|
||||
}, [breadcrumbs, context.activeTicker, pathname]);
|
||||
|
||||
const prefetchForHref = (href: string) => {
|
||||
router.prefetch(href);
|
||||
|
||||
if (href.startsWith('/analysis')) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/financials')) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/filings')) {
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({
|
||||
ticker: context.activeTicker ?? undefined,
|
||||
limit: 120
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/portfolio')) {
|
||||
void queryClient.prefetchQuery(holdingsQueryOptions());
|
||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
||||
void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/watchlist')) {
|
||||
void queryClient.prefetchQuery(watchlistQueryOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
if (href === '/') {
|
||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({ limit: 200 }));
|
||||
void queryClient.prefetchQuery(watchlistQueryOptions());
|
||||
void queryClient.prefetchQuery(recentTasksQueryOptions(20));
|
||||
void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const browserWindow = window as Window & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
const runPrefetch = () => {
|
||||
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'filings' || entry.id === 'portfolio');
|
||||
for (const entry of prioritized) {
|
||||
prefetchForHref(entry.href);
|
||||
}
|
||||
};
|
||||
|
||||
if (browserWindow.requestIdleCallback) {
|
||||
const idleId = browserWindow.requestIdleCallback(() => {
|
||||
runPrefetch();
|
||||
});
|
||||
|
||||
return () => {
|
||||
browserWindow.cancelIdleCallback?.(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
runPrefetch();
|
||||
}, 320);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [navEntries]);
|
||||
|
||||
const signOut = async () => {
|
||||
if (isSigningOut) {
|
||||
return;
|
||||
@@ -67,27 +367,34 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
<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>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-all duration-200',
|
||||
isActive
|
||||
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[0_0_18px_rgba(0,255,180,0.16)]'
|
||||
: '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" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border 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',
|
||||
item.active
|
||||
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[0_0_18px_rgba(0,255,180,0.16)]'
|
||||
: '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" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
@@ -104,8 +411,8 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-6 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-6 py-5 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)]">
|
||||
<div className="min-w-0 flex-1 pb-24 lg:pb-0">
|
||||
<header className="mb-4 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-6 py-5 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Live System</p>
|
||||
@@ -124,32 +431,130 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="mb-6 flex gap-2 overflow-x-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-2 lg:hidden">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="mb-6 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2"
|
||||
>
|
||||
<ol className="flex flex-wrap items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-xs transition',
|
||||
isActive
|
||||
? '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)]'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
onMouseEnter={() => 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)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(isLast ? 'text-[color:var(--terminal-bright)]' : '')} aria-current={isLast ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isLast ? <span aria-hidden="true">/</span> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 space-y-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:rgba(5,14,22,0.96)] px-2 py-2 backdrop-blur lg:hidden"
|
||||
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)' }}>
|
||||
{mobilePrimaryEntries.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
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)]',
|
||||
item.active
|
||||
? '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)]'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isMoreOpen}
|
||||
onClick={() => setIsMoreOpen((prev) => !prev)}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] text-[color:var(--terminal-muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
|
||||
>
|
||||
<Menu className="size-4" />
|
||||
<span>More</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{isMoreOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden" onClick={() => setIsMoreOpen(false)}>
|
||||
<div
|
||||
role="dialog"
|
||||
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)]"
|
||||
style={{ bottom: 'calc(4.7rem + env(safe-area-inset-bottom))' }}
|
||||
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>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{mobileMoreEntries.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
onClick={() => setIsMoreOpen(false)}
|
||||
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)]',
|
||||
item.active
|
||||
? '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)]'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
void signOut();
|
||||
}}
|
||||
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" />
|
||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user