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>
This commit is contained in:
230
web/src/components/AppShell.tsx
Normal file
230
web/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user