Files
Spendlight/web/src/components/AppShell.tsx
Chris Olson c3cf0f3eb8 Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:52:07 -04:00

231 lines
8.9 KiB
TypeScript

import { Ellipsis, Upload } from 'lucide-react';
import { Suspense, useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useReviewCount } from '@/lib/data';
import { queueFiles } from '@/lib/pendingFiles';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
import { APP_VERSION } from '@/lib/version';
import { LogoMark, VersionTag } from './brand';
import { IdeaButton } from './IdeaDialog';
import { NAV, SETTINGS_NAV, type NavItem } from './nav';
import { Button } from './ui/button';
import { Skeleton, Tip } from './ui/misc';
import { UserMenu } from './UserMenu';
function pageTitle(pathname: string): string {
if (pathname === '/') return 'Home';
if (pathname.startsWith('/settings')) return 'Settings';
if (pathname.startsWith('/more')) return 'More';
if (pathname.startsWith('/import')) return 'Import';
return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight';
}
function CountBadge({ count, className }: { count: number | undefined; className?: string }) {
if (!count) return null;
return (
<span className={cn('tabular flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground', className)}>
{count > 999 ? '999+' : count}
</span>
);
}
function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
const Icon = item.icon;
return (
<NavLink
to={item.to}
end={item.to === '/'}
title={item.label}
className={({ isActive }) =>
cn(
'flex h-11 items-center gap-3 rounded-xl px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:justify-center lg:justify-start',
isActive && 'bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground',
)
}
>
<span className="relative">
<Icon className="size-[19px] shrink-0" strokeWidth={1.9} />
<CountBadge count={badge} className="absolute -right-2.5 -top-2 h-4 min-w-4 px-1 text-[10px] lg:hidden" />
</span>
<span className="flex-1 md:sr-only lg:not-sr-only">{item.label}</span>
<CountBadge count={badge} className="hidden lg:flex" />
</NavLink>
);
}
function Sidebar() {
const { data: toReview } = useReviewCount();
return (
<aside className="fixed inset-y-0 left-0 z-40 hidden flex-col border-r bg-card/70 backdrop-blur-xl md:flex md:w-[84px] lg:w-64">
<Link to="/" className="flex h-16 items-center gap-2.5 px-6 md:justify-center lg:justify-start" aria-label="Spendlight home">
<LogoMark />
<span className="hidden text-[17px] font-semibold tracking-tight lg:inline">Spendlight</span>
</Link>
<nav className="flex flex-1 flex-col gap-1 px-3 py-2" aria-label="Main">
{NAV.map((item) => (
<SideLink key={item.to} item={item} badge={item.to === '/review' ? toReview : undefined} />
))}
</nav>
<div className="px-3 pb-4">
<SideLink item={SETTINGS_NAV} />
<div className="mt-3 px-3 md:text-center lg:text-left">
<VersionTag />
</div>
</div>
</aside>
);
}
function PhoneTabBar() {
const { pathname } = useLocation();
const { data: toReview } = useReviewCount();
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors';
return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/85 pb-safe backdrop-blur-xl md:hidden" aria-label="Main">
<div className="mx-auto grid max-w-md grid-cols-5">
{NAV.filter((n) => n.phone).map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => cn(tab, isActive && 'text-primary')}>
<span className="relative">
<Icon className="size-[22px]" strokeWidth={1.9} />
{to === '/review' ? <CountBadge count={toReview} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" /> : null}
</span>
{label}
</NavLink>
))}
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
More
</NavLink>
</div>
</nav>
);
}
/** When the server is running a newer version than these screens, offer a reload. */
function UpdateNotice() {
const { data } = useConfig();
useEffect(() => {
if (!data || data.version === 'dev' || APP_VERSION === 'dev' || data.version === APP_VERSION) return;
toast('A new version of Spendlight is ready', {
id: 'update',
description: `Version ${data.version}`,
duration: Infinity,
action: { label: 'Reload', onClick: () => window.location.reload() },
});
}, [data]);
return null;
}
export function PageSkeleton() {
return (
<div className="grid gap-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
</div>
);
}
/** Drop statement files anywhere on a big screen to start importing them. */
function useFileDrop(enabled: boolean) {
const navigate = useNavigate();
const [over, setOver] = useState(false);
useEffect(() => {
if (!enabled) return;
let depth = 0;
const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files');
const enter = (e: DragEvent) => {
if (!hasFiles(e)) return;
depth++;
setOver(true);
};
const leave = (e: DragEvent) => {
if (!hasFiles(e)) return;
depth = Math.max(0, depth - 1);
if (!depth) setOver(false);
};
const overHandler = (e: DragEvent) => {
if (hasFiles(e)) e.preventDefault();
};
const drop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0;
setOver(false);
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && !window.location.pathname.startsWith('/import')) {
queueFiles(files);
navigate('/import');
}
};
window.addEventListener('dragenter', enter);
window.addEventListener('dragleave', leave);
window.addEventListener('dragover', overHandler);
window.addEventListener('drop', drop);
return () => {
window.removeEventListener('dragenter', enter);
window.removeEventListener('dragleave', leave);
window.removeEventListener('dragover', overHandler);
window.removeEventListener('drop', drop);
};
}, [enabled, navigate]);
return over;
}
export function AppShell() {
const { data: config } = useConfig();
const me = useCurrentUser();
const { pathname } = useLocation();
const dropping = useFileDrop(me.access === 'full' && !pathname.startsWith('/import'));
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return (
<div className="min-h-dvh">
<Sidebar />
<div className="md:pl-[84px] lg:pl-64">
<header className="sticky top-0 z-30 border-b bg-background/80 pt-safe backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6 lg:px-8">
<Link to="/" className="mr-1 md:hidden" aria-label="Spendlight home">
<LogoMark className="size-7" />
</Link>
<h1 className="flex-1 truncate text-[17px] font-semibold tracking-tight">{pageTitle(pathname)}</h1>
{me.access === 'full' ? (
<Tip label="Import statements">
<Button variant="ghost" size="icon" asChild aria-label="Import statements">
<Link to="/import">
<Upload />
</Link>
</Button>
</Tip>
) : null}
{config?.ideasEnabled ? <IdeaButton /> : null}
<UserMenu />
</div>
</header>
<main className="mx-auto max-w-6xl px-4 pb-32 pt-5 sm:px-6 md:pb-14 md:pt-8 lg:px-8">
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</main>
</div>
<PhoneTabBar />
<UpdateNotice />
{dropping ? (
<div className="pointer-events-none fixed inset-0 z-[60] flex items-center justify-center bg-night/50 p-8 backdrop-blur-sm">
<div className="flex flex-col items-center rounded-3xl border-2 border-dashed border-white/50 px-16 py-12 text-center text-white">
<Upload className="mb-3 size-10" strokeWidth={1.6} />
<p className="text-xl font-semibold">Drop to import</p>
<p className="mt-1 text-white/75">Statements from any of your banks</p>
</div>
</div>
) : null}
</div>
);
}