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>
231 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|