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:
23
web/index.html
Normal file
23
web/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Spendlight</title>
|
||||
<meta name="description" content="Your household's money, in the spotlight." />
|
||||
<meta name="theme-color" content="#FAFAF7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0B1020" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Spendlight" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
web/package.json
Normal file
36
web/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@spendlight/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"icons": "node scripts/make-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@spendlight/shared": "*",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"radix-ui": "^1.7.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.3"
|
||||
}
|
||||
}
|
||||
18
web/public/favicon.svg
Normal file
18
web/public/favicon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="beam" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FBBF24" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="#F97362" stop-opacity="0.18"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="pool" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#FCD34D"/>
|
||||
<stop offset="0.55" stop-color="#F97362" stop-opacity="0.6"/>
|
||||
<stop offset="1" stop-color="#F97362" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="116" fill="#0B1020"/>
|
||||
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill="url(#beam)"/>
|
||||
<ellipse cx="256" cy="380" rx="168" ry="50" fill="url(#pool)"/>
|
||||
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8"/>
|
||||
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
BIN
web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
16
web/public/manifest.webmanifest
Normal file
16
web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Spendlight",
|
||||
"short_name": "Spendlight",
|
||||
"description": "Your household's money, in the spotlight.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0B1020",
|
||||
"theme_color": "#0B1020",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
57
web/public/sw.js
Normal file
57
web/public/sw.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Spendlight service worker. Makes the home-screen app open instantly and
|
||||
// show its shell offline. It never touches /api: household data is always
|
||||
// fetched fresh and never stored here.
|
||||
|
||||
const SHELL = 'spendlight-shell';
|
||||
const ASSETS = 'spendlight-assets';
|
||||
const MAX_ASSETS = 80;
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== ASSETS).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Pages: always try the network first so a new version shows up at once.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(SHELL).then((cache) => cache.put('/', copy));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('/').then((cached) => cached || Response.error())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Versioned files never change, so a cached copy is always right.
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(
|
||||
caches.open(ASSETS).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > MAX_ASSETS) await Promise.all(keys.slice(0, keys.length - MAX_ASSETS).map((k) => cache.delete(k)));
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
9
web/public/theme-init.js
Normal file
9
web/public/theme-init.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Runs before the page paints, so dark mode never flashes white.
|
||||
(function () {
|
||||
try {
|
||||
var choice = localStorage.getItem('sl-theme') || 'system';
|
||||
var dark = choice === 'dark' || (choice === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
} catch (e) {}
|
||||
})();
|
||||
19
web/scripts/make-icons.mjs
Normal file
19
web/scripts/make-icons.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Render the app icons from public/favicon.svg. Run with: npm run icons
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const svg = readFileSync(new URL('../public/favicon.svg', import.meta.url));
|
||||
const out = (name) => new URL(`../public/icons/${name}`, import.meta.url).pathname;
|
||||
const night = { r: 11, g: 16, b: 32, alpha: 1 };
|
||||
|
||||
await sharp(svg, { density: 384 }).resize(192, 192).png().toFile(out('icon-192.png'));
|
||||
await sharp(svg, { density: 384 }).resize(512, 512).png().toFile(out('icon-512.png'));
|
||||
// iOS adds its own rounded corners and dislikes transparency: full-bleed square.
|
||||
await sharp(svg, { density: 384 }).resize(180, 180).flatten({ background: night }).png().toFile(out('apple-touch-icon.png'));
|
||||
// Maskable: the mark inside the 80% safe zone on a full-bleed background.
|
||||
const inner = await sharp(svg, { density: 384 }).resize(360, 360).png().toBuffer();
|
||||
await sharp({ create: { width: 512, height: 512, channels: 4, background: night } })
|
||||
.composite([{ input: inner, gravity: 'center' }])
|
||||
.png()
|
||||
.toFile(out('icon-maskable-512.png'));
|
||||
console.log('Icons written to public/icons/');
|
||||
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>
|
||||
);
|
||||
}
|
||||
33
web/src/components/AuthLayout.tsx
Normal file
33
web/src/components/AuthLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useConfig } from '@/lib/session';
|
||||
import { LogoMark, VersionTag } from './brand';
|
||||
|
||||
/** The calm, centered frame for setup, sign-in and the first password change. */
|
||||
export function AuthLayout({ title, subtitle, children }: { title: string; subtitle?: ReactNode; children: ReactNode }) {
|
||||
const { data } = useConfig();
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center px-5 pt-safe">
|
||||
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-[460px]" aria-hidden />
|
||||
<div className="relative flex w-full max-w-sm flex-1 flex-col justify-center py-12">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<LogoMark className="mb-5 size-16 rounded-[22px] shadow-lift" />
|
||||
<h1 className="text-[26px] font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 text-[15px] leading-relaxed text-muted-foreground">{subtitle}</p> : null}
|
||||
</div>
|
||||
<div className="animate-fade-up rounded-3xl border bg-card p-6 shadow-lift sm:p-7">{children}</div>
|
||||
</div>
|
||||
<footer className="relative pb-6 pb-safe">
|
||||
<VersionTag version={data?.version} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p role="alert" className="rounded-xl bg-bad/10 px-3.5 py-2.5 text-[13.5px] leading-snug text-bad">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
202
web/src/components/CategoryPicker.tsx
Normal file
202
web/src/components/CategoryPicker.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { Category } from '@spendlight/shared';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive, Popover } from 'radix-ui';
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { recentCategories, rememberCategory, useCategories } from '@/lib/data';
|
||||
import { useIsPhone } from '@/lib/useMediaQuery';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryIcon } from './money';
|
||||
|
||||
// The one category picker used everywhere: a flat, searchable list. Suggested
|
||||
// and recent picks sit on top, then every category A to Z with its group as a
|
||||
// small label. Phones get a full-height sheet; bigger screens a popover.
|
||||
|
||||
interface PickerProps {
|
||||
value: number | null;
|
||||
suggestions?: number[];
|
||||
onPick: (category: Category) => void;
|
||||
children: ReactNode;
|
||||
/** Hide transfer categories (for splits, which are always spending or income). */
|
||||
noTransfers?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function PickerList({
|
||||
value,
|
||||
suggestions = [],
|
||||
onPick,
|
||||
noTransfers,
|
||||
autoFocus,
|
||||
}: Omit<PickerProps, 'children' | 'open' | 'onOpenChange'> & { autoFocus: boolean }) {
|
||||
const { all, byId } = useCategories();
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const usable = all.filter((c) => !c.archived && !(noTransfers && c.kind === 'transfer'));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
const matches = usable
|
||||
.filter((c) => c.name.toLowerCase().includes(q) || c.groupName.toLowerCase().includes(q))
|
||||
.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)) || a.name.localeCompare(b.name));
|
||||
return [{ title: `${matches.length} match${matches.length === 1 ? '' : 'es'}`, items: matches }];
|
||||
}
|
||||
const pick = (ids: number[]) => ids.map((id) => byId.get(id)).filter((c): c is Category => !!c && usable.includes(c));
|
||||
const suggested = pick(suggestions);
|
||||
const recent = pick(recentCategories()).filter((c) => !suggested.includes(c)).slice(0, 4);
|
||||
return [
|
||||
...(suggested.length ? [{ title: 'Suggested', items: suggested }] : []),
|
||||
...(recent.length ? [{ title: 'Recent', items: recent }] : []),
|
||||
{ title: 'All categories', items: [...usable].sort((a, b) => a.name.localeCompare(b.name)) },
|
||||
];
|
||||
}, [all, byId, query, suggestions, noTransfers]);
|
||||
|
||||
const flat = sections.flatMap((s) => s.items);
|
||||
const choose = (c: Category) => {
|
||||
rememberCategory(c.id);
|
||||
onPick(c);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = Math.max(0, Math.min(flat.length - 1, active + (e.key === 'ArrowDown' ? 1 : -1)));
|
||||
setActive(next);
|
||||
listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter' && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]!);
|
||||
}
|
||||
};
|
||||
|
||||
let index = -1;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" onKeyDown={onKeyDown}>
|
||||
<div className="relative border-b p-2.5">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
placeholder="Search categories"
|
||||
aria-label="Search categories"
|
||||
className="h-11 w-full rounded-xl bg-muted pl-9 pr-3 text-[16px] outline-none placeholder:text-muted-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/30 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5" role="listbox" aria-label="Categories">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="mb-1">
|
||||
<p className="px-2.5 pb-1 pt-2 text-[11.5px] font-semibold uppercase tracking-wider text-muted-foreground">{section.title}</p>
|
||||
{section.items.map((c) => {
|
||||
index++;
|
||||
const i = index;
|
||||
return (
|
||||
<button
|
||||
key={`${section.title}-${c.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={c.id === value}
|
||||
data-index={i}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => choose(c)}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center gap-3 rounded-xl px-2.5 text-left sm:h-10',
|
||||
i === active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-medium sm:text-[14px]">{c.name}</span>
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground">{c.groupName}</span>
|
||||
{c.id === value ? <Check className="size-4 shrink-0 text-primary" /> : <span className="size-4 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{section.items.length === 0 ? <p className="px-2.5 py-3 text-[13.5px] text-muted-foreground">Nothing matches that.</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryPicker({ children, open: controlledOpen, onOpenChange, ...props }: PickerProps) {
|
||||
const phone = useIsPhone();
|
||||
const [uncontrolled, setUncontrolled] = useState(false);
|
||||
const open = controlledOpen ?? uncontrolled;
|
||||
const setOpen = onOpenChange ?? setUncontrolled;
|
||||
const pick = (c: Category) => {
|
||||
setOpen(false);
|
||||
props.onPick(c);
|
||||
};
|
||||
|
||||
if (phone) {
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<DialogPrimitive.Trigger asChild>{children}</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-night/40 backdrop-blur-[2px]" />
|
||||
<DialogPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 flex h-[88dvh] animate-sheet-up flex-col rounded-t-3xl border bg-popover pb-safe text-popover-foreground shadow-lift outline-none">
|
||||
<div className="mx-auto mt-2.5 h-1.5 w-10 rounded-full bg-muted" aria-hidden />
|
||||
<DialogPrimitive.Title className="px-4 pt-3 text-[17px] font-semibold">Choose a category</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">Pick the category for this transaction</DialogPrimitive.Description>
|
||||
<PickerList {...props} onPick={pick} autoFocus={false} />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>{children}</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
collisionPadding={12}
|
||||
className="z-50 flex h-[min(440px,var(--radix-popover-content-available-height))] w-80 animate-fade-up flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-lift"
|
||||
>
|
||||
<PickerList {...props} onPick={pick} autoFocus />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tappable chip showing a category (or "Choose category"), opening the picker. */
|
||||
export function CategoryButton({
|
||||
categoryId,
|
||||
suggestions,
|
||||
onPick,
|
||||
className,
|
||||
placeholder = 'Choose category',
|
||||
}: {
|
||||
categoryId: number | null;
|
||||
suggestions?: number[];
|
||||
onPick: (category: Category) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { byId } = useCategories();
|
||||
const c = categoryId ? byId.get(categoryId) : undefined;
|
||||
return (
|
||||
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-10 min-w-0 items-center gap-2 rounded-xl border bg-card px-2.5 text-left text-[14px] font-medium shadow-soft transition-colors hover:bg-muted',
|
||||
!c && 'border-dashed text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{c ? <CategoryIcon icon={c.icon} color={c.color} size="sm" className="-ml-0.5 size-6 rounded-md" /> : null}
|
||||
<span className="truncate">{c?.name ?? placeholder}</span>
|
||||
</button>
|
||||
</CategoryPicker>
|
||||
);
|
||||
}
|
||||
82
web/src/components/IdeaDialog.tsx
Normal file
82
web/src/components/IdeaDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Idea } from '@spendlight/shared';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog';
|
||||
import { Field, Input, Textarea } from './ui/form';
|
||||
import { Tip } from './ui/misc';
|
||||
|
||||
/** The lightbulb at the top right: share an idea in a few seconds. */
|
||||
export function IdeaButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api<Idea>('/ideas', { method: 'POST', body: { title, description, path: location.pathname } }),
|
||||
onSuccess: () => {
|
||||
toast.success('Thanks! Your idea is saved.', { description: 'It’s on the list to review.' });
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) save.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tip label="Share an idea">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Share an idea" className="text-muted-foreground hover:text-warn">
|
||||
<Lightbulb />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Tip>
|
||||
<DialogContent
|
||||
title="Share an idea"
|
||||
description="Spotted something Spendlight should do, or do better? Jot it down and it goes straight onto the list."
|
||||
>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Idea">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="For example: show our coffee total for the year"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Details" hint="What would it help with? Any example helps.">
|
||||
{(id) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={4000}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" loading={save.isPending} disabled={!title.trim()} className="mt-1 w-full sm:w-auto sm:justify-self-end">
|
||||
Save idea
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
170
web/src/components/MappingDialog.tsx
Normal file
170
web/src/components/MappingDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { ImportColumnMap, ImportPreview } from '@spendlight/shared';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
import { Select } from './ui/form';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// For a bank Spendlight hasn't seen: "which column is which?", with the file's
|
||||
// own first rows underneath, and one plain question to get the sign right.
|
||||
|
||||
type Role = 'skip' | 'date' | 'description' | 'amount' | 'debit' | 'credit' | 'balance' | 'category' | 'postedDate';
|
||||
|
||||
const ROLE_LABEL: Record<Role, string> = {
|
||||
skip: 'Skip',
|
||||
date: 'Date',
|
||||
description: 'Description',
|
||||
amount: 'Amount',
|
||||
debit: 'Money out',
|
||||
credit: 'Money in',
|
||||
balance: 'Balance',
|
||||
category: 'Bank’s category',
|
||||
postedDate: 'Posted date',
|
||||
};
|
||||
|
||||
function rolesFrom(map: ImportColumnMap | null, columns: number): Role[] {
|
||||
const roles: Role[] = Array.from({ length: columns }, () => 'skip');
|
||||
if (!map) return roles;
|
||||
for (const role of ['date', 'description', 'amount', 'debit', 'credit', 'balance', 'category', 'postedDate'] as const) {
|
||||
const i = map[role];
|
||||
if (i !== undefined && i < columns) roles[i] = role;
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
function guessDateOrder(rows: string[][], column: number): ImportColumnMap['dateOrder'] {
|
||||
const values = rows.map((r) => r[column] ?? '');
|
||||
if (values.some((v) => /^\d{4}[-/.]/.test(v))) return 'YMD';
|
||||
if (values.some((v) => Number(v.split(/[/.-]/)[0]) > 12)) return 'DMY';
|
||||
return 'MDY';
|
||||
}
|
||||
|
||||
const money = (s: string) => Number(s.replace(/[$,\s()]/g, '').replace(/^\((.*)\)$/, '-$1')) || 0;
|
||||
|
||||
export function MappingDialog({
|
||||
preview,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
preview: ImportPreview;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (map: ImportColumnMap) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const header = preview.headerRow ?? preview.sampleRows[0]?.map((_, i) => `Column ${i + 1}`) ?? [];
|
||||
const rows = preview.sampleRows;
|
||||
const [roles, setRoles] = useState<Role[]>(() => rolesFrom(preview.suggestedMap, header.length));
|
||||
const dateCol = roles.indexOf('date');
|
||||
const [dateOrder, setDateOrder] = useState<ImportColumnMap['dateOrder']>(() =>
|
||||
preview.suggestedMap ? guessDateOrder(rows, preview.suggestedMap.date) : 'MDY',
|
||||
);
|
||||
const [spent, setSpent] = useState<boolean | null>(null);
|
||||
|
||||
const amountCol = roles.indexOf('amount');
|
||||
const example = useMemo(() => {
|
||||
if (amountCol < 0) return null;
|
||||
const descCol = roles.indexOf('description');
|
||||
const row = rows.find((r) => money(r[amountCol] ?? '') !== 0);
|
||||
return row ? { desc: descCol >= 0 ? row[descCol] : 'the first row', amount: row[amountCol]!, value: money(row[amountCol]!) } : null;
|
||||
}, [rows, roles, amountCol]);
|
||||
|
||||
const setRole = (i: number, role: Role) =>
|
||||
setRoles((prev) => prev.map((r, j) => (j === i ? role : role !== 'skip' && r === role ? 'skip' : r)));
|
||||
|
||||
const complete =
|
||||
dateCol >= 0 && roles.includes('description') && (roles.includes('debit') || (amountCol >= 0 && (spent !== null || !example)));
|
||||
|
||||
const save = () => {
|
||||
const map: ImportColumnMap = {
|
||||
date: dateCol,
|
||||
description: roles.indexOf('description'),
|
||||
dateOrder,
|
||||
positiveIs: example && spent !== null ? ((example.value > 0) === spent ? 'out' : 'in') : 'in',
|
||||
};
|
||||
roles.forEach((role, i) => {
|
||||
if (role !== 'skip' && role !== 'date' && role !== 'description') map[role] = i;
|
||||
});
|
||||
onSave(map);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title="Which column is which?"
|
||||
description="Spendlight hasn’t seen this bank’s layout before. Show it once and it will remember."
|
||||
className="sm:max-w-3xl"
|
||||
>
|
||||
<div className="-mx-5 overflow-x-auto px-5 sm:-mx-6 sm:px-6">
|
||||
<table className="w-full min-w-max border-separate border-spacing-0 text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{header.map((name, i) => (
|
||||
<th key={i} className="min-w-36 pb-2 pr-2 text-left align-bottom font-medium">
|
||||
<div className="mb-1 truncate text-[12px] text-muted-foreground">{name || `Column ${i + 1}`}</div>
|
||||
<Select
|
||||
aria-label={`What is ${name || `column ${i + 1}`}?`}
|
||||
value={roles[i]}
|
||||
onChange={(e) => setRole(i, e.target.value as Role)}
|
||||
className={cn('h-9 text-[13px]', roles[i] !== 'skip' && 'border-primary/50 bg-accent text-accent-foreground')}
|
||||
>
|
||||
{(Object.keys(ROLE_LABEL) as Role[]).map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r}>
|
||||
{header.map((_, i) => (
|
||||
<td key={i} className={cn('border-t py-2 pr-3 text-muted-foreground', roles[i] !== 'skip' && 'text-foreground')}>
|
||||
<span className="block max-w-56 truncate">{row[i]}</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
{dateCol >= 0 ? (
|
||||
<label className="flex flex-wrap items-center justify-between gap-2 text-[14px]">
|
||||
<span className="font-medium">Dates are written</span>
|
||||
<Select value={dateOrder} onChange={(e) => setDateOrder(e.target.value as ImportColumnMap['dateOrder'])} className="h-9 w-auto">
|
||||
<option value="MDY">Month / day / year</option>
|
||||
<option value="DMY">Day / month / year</option>
|
||||
<option value="YMD">Year - month - day</option>
|
||||
</Select>
|
||||
</label>
|
||||
) : null}
|
||||
{example ? (
|
||||
<div className="rounded-2xl bg-muted p-4">
|
||||
<p className="text-[14px] font-medium">
|
||||
Was “{example.desc}” for {example.amount} money you spent?
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant={spent === true ? 'default' : 'outline'} onClick={() => setSpent(true)}>
|
||||
Yes, money spent
|
||||
</Button>
|
||||
<Button size="sm" variant={spent === false ? 'default' : 'outline'} onClick={() => setSpent(false)}>
|
||||
No, money received
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Button onClick={save} disabled={!complete} loading={saving}>
|
||||
{complete ? 'Looks right' : 'Choose the date, description and amount columns'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
101
web/src/components/RequireAuth.tsx
Normal file
101
web/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Me } from '@spendlight/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useConfig, useMe } from '@/lib/session';
|
||||
import { applyTheme, storedTheme } from '@/lib/theme';
|
||||
import { AuthLayout, FormError } from './AuthLayout';
|
||||
import { LogoMark } from './brand';
|
||||
import { Button } from './ui/button';
|
||||
import { Field, PasswordInput } from './ui/form';
|
||||
|
||||
export function Splash() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<LogoMark className="size-14 animate-pulse rounded-[20px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Unreachable({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<AuthLayout title="Can’t reach Spendlight" subtitle="The server didn’t answer. Check your connection, or try again in a moment.">
|
||||
<Button className="w-full" onClick={onRetry}>
|
||||
<RefreshCw /> Try again
|
||||
</Button>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/** First sign-in with a temporary password: choose your own before anything else. */
|
||||
function ChangePasswordGate() {
|
||||
const client = useQueryClient();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const me = await api<Me>('/me/password', { method: 'POST', body: { currentPassword: current, newPassword: next } });
|
||||
client.setQueryData(['me'], me);
|
||||
toast.success('Password saved. You’re all set.');
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Choose your password" subtitle="You signed in with a temporary password. Pick one only you know.">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<FormError message={error} />
|
||||
<Field label="Temporary password">
|
||||
{(id) => <PasswordInput id={id} value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />}
|
||||
</Field>
|
||||
<Field label="New password" hint="At least 8 characters. A short phrase is easy to remember.">
|
||||
{(id) => <PasswordInput id={id} value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={8} required />}
|
||||
</Field>
|
||||
<Button type="submit" size="lg" loading={busy} className="mt-1 w-full">
|
||||
Save and continue
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const config = useConfig();
|
||||
const me = useMe();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (me.data && me.data.theme !== storedTheme()) applyTheme(me.data.theme);
|
||||
}, [me.data]);
|
||||
|
||||
if (config.isError || me.isError) {
|
||||
return (
|
||||
<Unreachable
|
||||
onRetry={() => {
|
||||
void config.refetch();
|
||||
void me.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (config.isPending || me.isPending) return <Splash />;
|
||||
if (config.data.needsSetup) return <Navigate to="/setup" replace />;
|
||||
if (!me.data) {
|
||||
const next = location.pathname === '/' ? '' : `?next=${encodeURIComponent(location.pathname)}`;
|
||||
return <Navigate to={`/sign-in${next}`} replace />;
|
||||
}
|
||||
if (me.data.mustChangePassword) return <ChangePasswordGate />;
|
||||
return children;
|
||||
}
|
||||
25
web/src/components/RouteError.tsx
Normal file
25
web/src/components/RouteError.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { RefreshCw, TriangleAlert } from 'lucide-react';
|
||||
import { useRouteError } from 'react-router';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/** Shown instead of a blank screen if something breaks while drawing a page. */
|
||||
export function RouteError() {
|
||||
const error = useRouteError();
|
||||
if (error) console.error(error);
|
||||
return (
|
||||
<div className="flex min-h-[60dvh] items-center justify-center px-4 py-16">
|
||||
<div className="max-w-sm text-center">
|
||||
<span className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-warn/12 text-warn">
|
||||
<TriangleAlert className="size-7" strokeWidth={1.8} />
|
||||
</span>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Something went wrong</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">
|
||||
This screen hit a problem. Reloading usually fixes it, and nothing you saved has been lost.
|
||||
</p>
|
||||
<Button className="mt-6" onClick={() => window.location.reload()}>
|
||||
<RefreshCw /> Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
web/src/components/TransactionSheet.tsx
Normal file
364
web/src/components/TransactionSheet.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { formatCents, type Transaction } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeftRight, Plus, Split as SplitIcon, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { MONEY_KEYS, useAccounts, useCategories, useTags } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryButton } from './CategoryPicker';
|
||||
import { AccountDot, Amount, formatDay } from './money';
|
||||
import { Button } from './ui/button';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from './ui/dialog';
|
||||
import { Field, Input, Textarea } from './ui/form';
|
||||
import { Switch } from './ui/misc';
|
||||
|
||||
function parseMoney(text: string): number | null {
|
||||
const clean = text.replace(/[$,\s]/g, '');
|
||||
if (!/^-?\d*(\.\d{0,2})?$/.test(clean) || clean === '' || clean === '-') return null;
|
||||
return Math.round(Number(clean) * 100);
|
||||
}
|
||||
|
||||
export function useRefreshMoney() {
|
||||
const client = useQueryClient();
|
||||
return () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
|
||||
}
|
||||
|
||||
function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: string[]) => void }) {
|
||||
const tags = useTags();
|
||||
const [draft, setDraft] = useState('');
|
||||
const add = (name: string) => {
|
||||
const clean = name.trim();
|
||||
if (clean && !value.some((t) => t.toLowerCase() === clean.toLowerCase())) onChange([...value, clean]);
|
||||
setDraft('');
|
||||
};
|
||||
const suggestions = (tags.data ?? []).map((t) => t.name).filter((n) => !value.some((v) => v.toLowerCase() === n.toLowerCase()));
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{value.length ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((t) => (
|
||||
<span key={t} className="flex items-center gap-1 rounded-full bg-accent py-1 pl-3 pr-1.5 text-[13px] font-medium text-accent-foreground">
|
||||
{t}
|
||||
<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(value.filter((v) => v !== t))} className="rounded-full p-0.5 hover:bg-primary/15">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={draft}
|
||||
list="tag-suggestions"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add(draft);
|
||||
}
|
||||
}}
|
||||
onBlur={() => draft && add(draft)}
|
||||
placeholder="Add a tag, like Vacation 2026"
|
||||
aria-label="Add a tag"
|
||||
/>
|
||||
<datalist id="tag-suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Part {
|
||||
key: string;
|
||||
categoryId: number | null;
|
||||
amount: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => void; onCancel: () => void }) {
|
||||
const sign = Math.sign(t.amountCents) || -1;
|
||||
const total = Math.abs(t.amountCents);
|
||||
const [parts, setParts] = useState<Part[]>(() =>
|
||||
t.isSplit
|
||||
? t.splits.map((s) => ({ key: crypto.randomUUID(), categoryId: s.categoryId, amount: (Math.abs(s.amountCents) / 100).toFixed(2), note: s.note ?? '' }))
|
||||
: [
|
||||
{ key: crypto.randomUUID(), categoryId: t.categoryId, amount: (total / 100).toFixed(2), note: '' },
|
||||
{ key: crypto.randomUUID(), categoryId: null, amount: '', note: '' },
|
||||
],
|
||||
);
|
||||
const assigned = parts.reduce((s, p) => s + (parseMoney(p.amount) ?? 0), 0);
|
||||
const left = total - assigned;
|
||||
const valid = left === 0 && parts.length >= 2 && parts.every((p) => p.categoryId && (parseMoney(p.amount) ?? 0) > 0);
|
||||
const refresh = useRefreshMoney();
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api<Transaction>(`/transactions/${t.id}/split`, {
|
||||
method: 'PUT',
|
||||
body: { parts: parts.map((p) => ({ categoryId: p.categoryId, amountCents: sign * (parseMoney(p.amount) ?? 0), note: p.note || null })) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Split saved');
|
||||
refresh();
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const update = (key: string, change: Partial<Part>) =>
|
||||
setParts((list) => {
|
||||
const next = list.map((p) => (p.key === key ? { ...p, ...change } : p));
|
||||
// Typing an amount in a later part takes it from the first part, so the total stays right.
|
||||
if (change.amount !== undefined && key !== next[0]!.key) {
|
||||
const others = next.slice(1).reduce((sum, p) => sum + (parseMoney(p.amount) ?? 0), 0);
|
||||
if (others <= total) next[0] = { ...next[0]!, amount: ((total - others) / 100).toFixed(2) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{parts.map((p, i) => (
|
||||
<div key={p.key} className="grid gap-2 rounded-2xl border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryButton categoryId={p.categoryId} onPick={(c) => update(p.key, { categoryId: c.id })} className="min-w-0 flex-1" />
|
||||
<Input
|
||||
value={p.amount}
|
||||
onChange={(e) => update(p.key, { amount: e.target.value })}
|
||||
inputMode="decimal"
|
||||
aria-label={`Amount for part ${i + 1}`}
|
||||
placeholder="0.00"
|
||||
className="w-28 text-right tabular"
|
||||
/>
|
||||
{parts.length > 2 ? (
|
||||
<Button variant="ghost" size="icon" aria-label="Remove this part" onClick={() => setParts((list) => list.filter((x) => x.key !== p.key))}>
|
||||
<X className="text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input value={p.note} onChange={(e) => update(p.key, { note: e.target.value })} placeholder="Note (optional), like Paper towels" aria-label={`Note for part ${i + 1}`} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setParts((list) => [...list, { key: crypto.randomUUID(), categoryId: null, amount: left > 0 ? (left / 100).toFixed(2) : '', note: '' }])}>
|
||||
<Plus /> Add a part
|
||||
</Button>
|
||||
<p className={cn('tabular text-[13.5px] font-medium', left === 0 ? 'text-good' : 'text-warn')}>
|
||||
{left === 0 ? 'All assigned' : left > 0 ? `${formatCents(left)} left to assign` : `${formatCents(-left)} too much`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={!valid} loading={save.isPending} className="flex-1">
|
||||
Save split
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything about one transaction, editable in place. */
|
||||
export function TransactionSheet({
|
||||
transaction,
|
||||
onClose,
|
||||
startWithSplit,
|
||||
}: {
|
||||
transaction: Transaction | null;
|
||||
onClose: () => void;
|
||||
startWithSplit?: boolean;
|
||||
}) {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const accounts = useAccounts();
|
||||
const { byId } = useCategories();
|
||||
const refresh = useRefreshMoney();
|
||||
const [t, setT] = useState<Transaction | null>(transaction);
|
||||
const [splitting, setSplitting] = useState(false);
|
||||
const [merchant, setMerchant] = useState('');
|
||||
const [everywhere, setEverywhere] = useState(false);
|
||||
const [note, setNote] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setT(transaction);
|
||||
setSplitting(!!startWithSplit);
|
||||
setMerchant(transaction?.merchant ?? '');
|
||||
setNote(transaction?.note ?? '');
|
||||
setEverywhere(false);
|
||||
}, [transaction, startWithSplit]);
|
||||
|
||||
const account = useMemo(() => accounts.data?.find((a) => a.id === t?.accountId), [accounts.data, t]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api<{ transaction: Transaction }>(`/transactions/${t!.id}`, { method: 'PATCH', body }),
|
||||
onSuccess: (res) => {
|
||||
setT(res.transaction);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const unsplit = useMutation({
|
||||
mutationFn: () => api<Transaction>(`/transactions/${t!.id}/split`, { method: 'DELETE' }),
|
||||
onSuccess: (res) => {
|
||||
setT(res);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api(`/transactions/${t!.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Deleted');
|
||||
refresh();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
if (!t) return null;
|
||||
const transfer = t.transferPeer;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={!!transaction && !confirmDelete} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent title={splitting ? 'Split this purchase' : t.merchant} description={splitting ? `Divide ${formatCents(t.amountCents)} between categories.` : undefined}>
|
||||
{splitting ? (
|
||||
<SplitEditor
|
||||
t={t}
|
||||
onCancel={() => setSplitting(false)}
|
||||
onDone={() => {
|
||||
setSplitting(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-5">
|
||||
<div className="flex items-end justify-between gap-3 rounded-2xl bg-muted p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
{account?.name} · {formatDay(t.date)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-[12px] text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<Amount cents={t.amountCents} transfer={!!transfer} className="text-[22px] font-semibold" />
|
||||
</div>
|
||||
|
||||
{transfer ? (
|
||||
<p className="flex items-center gap-2 rounded-xl border px-3.5 py-3 text-[14px]">
|
||||
<ArrowLeftRight className="size-4 text-muted-foreground" />
|
||||
<span>
|
||||
<em>Transfer {t.amountCents < 0 ? 'to' : 'from'} {transfer.accountName}</em>. Not counted as spending or income.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{t.isSplit ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-[13px] font-medium">Split between</p>
|
||||
{t.splits.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 rounded-xl border px-3 py-2.5 text-[14px]">
|
||||
<span className="flex-1 truncate">
|
||||
{byId.get(s.categoryId)?.name}
|
||||
{s.note ? <span className="text-muted-foreground"> · {s.note}</span> : null}
|
||||
</span>
|
||||
<span className="tabular">{formatCents(s.amountCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
{canEdit ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setSplitting(true)}>
|
||||
Edit split
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => unsplit.mutate()}>
|
||||
Remove split
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Category</span>
|
||||
<div className="flex gap-2">
|
||||
{canEdit ? (
|
||||
<CategoryButton
|
||||
categoryId={t.categoryId}
|
||||
onPick={(c) => save.mutate({ categoryId: c.id })}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[14.5px]">{t.categoryId ? byId.get(t.categoryId)?.name : 'Not filed yet'}</p>
|
||||
)}
|
||||
{canEdit && !transfer ? (
|
||||
<Button variant="outline" onClick={() => setSplitting(true)}>
|
||||
<SplitIcon /> Split
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Field label="Merchant name">
|
||||
{(id) => (
|
||||
<div className="grid gap-2">
|
||||
<Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} maxLength={80} />
|
||||
{merchant.trim() && merchant.trim() !== t.merchant ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<Switch checked={everywhere} onCheckedChange={setEverywhere} />
|
||||
Rename everywhere, and in future imports
|
||||
</label>
|
||||
<Button size="sm" loading={save.isPending} onClick={() => save.mutate({ merchant: merchant.trim(), renameEverywhere: everywhere })}>
|
||||
Save name
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Tags">{() => <TagEditor value={t.tags} onChange={(tags) => save.mutate({ tags })} />}</Field>
|
||||
<Field label="Note">
|
||||
{(id) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={() => note !== (t.note ?? '') && save.mutate({ note: note || null })}
|
||||
placeholder="Anything to remember about this one"
|
||||
className="min-h-20"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{t.source === 'manual' ? (
|
||||
<Button variant="ghost" className="justify-self-start text-bad hover:text-bad" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 /> Delete this entry
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t.tags.length ? <p className="text-[14px]">Tags: {t.tags.join(', ')}</p> : null}
|
||||
{t.note ? <p className="text-[14px]">{t.note}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
title="Delete this entry?"
|
||||
description="It was added by hand, so deleting it removes it completely."
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
web/src/components/UserMenu.tsx
Normal file
71
web/src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Theme } from '@spendlight/shared';
|
||||
import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useSetTheme } from '@/lib/useSetTheme';
|
||||
import { useCurrentUser, useSignOut } from '@/lib/session';
|
||||
import { initials } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/menu';
|
||||
|
||||
export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`relative flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-glow-1 to-glow-2 p-[2px] ${className}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="flex size-full items-center justify-center rounded-full bg-card font-semibold text-foreground">
|
||||
{initials(name)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const THEMES: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'system', label: 'Match my device', icon: Monitor },
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
];
|
||||
|
||||
export function UserMenu() {
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const signOut = useSignOut();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Your account">
|
||||
<Avatar name={me.displayName} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="text-[14px] font-semibold">{me.displayName}</div>
|
||||
<div className="text-[12.5px] font-normal text-muted-foreground">@{me.username}</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{THEMES.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenuItem key={value} onSelect={() => setTheme(value)}>
|
||||
<Icon />
|
||||
<span className="flex-1">{label}</span>
|
||||
{me.theme === value ? <span className="size-1.5 rounded-full bg-primary" aria-label="(current)" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => signOut.mutate()}>
|
||||
<LogOut />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
49
web/src/components/brand.tsx
Normal file
49
web/src/components/brand.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
|
||||
/** The Spendlight mark: a spotlight pouring warm light into a pool. */
|
||||
export function LogoMark({ className }: { className?: string }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" className={cn('size-8 shrink-0', className)} aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id={`${id}b`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#FBBF24" stopOpacity="0.95" />
|
||||
<stop offset="1" stopColor="#F97362" stopOpacity="0.18" />
|
||||
</linearGradient>
|
||||
<radialGradient id={`${id}p`} cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stopColor="#FCD34D" />
|
||||
<stop offset="0.55" stopColor="#F97362" stopOpacity="0.6" />
|
||||
<stop offset="1" stopColor="#F97362" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="116" fill="#0B1020" />
|
||||
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill={`url(#${id}b)`} />
|
||||
<ellipse cx="256" cy="380" rx="168" ry="50" fill={`url(#${id}p)`} />
|
||||
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8" />
|
||||
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-2.5', className)}>
|
||||
<LogoMark />
|
||||
<span className="text-[17px] font-semibold tracking-tight">Spendlight</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The build version, always visible somewhere so it's clear which code is running. */
|
||||
export function VersionTag({ className, version = APP_VERSION }: { className?: string; version?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('tabular text-[11px] font-medium tracking-wide text-muted-foreground/80', className)}
|
||||
title="Spendlight version: build date, then that day's build number"
|
||||
>
|
||||
v{version}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
152
web/src/components/money.tsx
Normal file
152
web/src/components/money.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { formatCents, type PaletteColor } from '@spendlight/shared';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Award,
|
||||
Baby,
|
||||
Banknote,
|
||||
Car,
|
||||
CarFront,
|
||||
Circle,
|
||||
CircleDollarSign,
|
||||
CircleParking,
|
||||
Coffee,
|
||||
CreditCard,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
Fuel,
|
||||
Gift,
|
||||
HeartHandshake,
|
||||
House,
|
||||
Landmark,
|
||||
MonitorSmartphone,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Percent,
|
||||
Pill,
|
||||
Plane,
|
||||
Receipt,
|
||||
Scale,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Shirt,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
Smartphone,
|
||||
Sofa,
|
||||
Sparkles,
|
||||
SprayCan,
|
||||
Stethoscope,
|
||||
Ticket,
|
||||
Tv,
|
||||
Utensils,
|
||||
Wifi,
|
||||
Wrench,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Colors and icons for categories and accounts, written out in full so the
|
||||
// styling tool can see every class.
|
||||
|
||||
export const PALETTE_CLASS: Record<PaletteColor, { soft: string; text: string; dot: string }> = {
|
||||
indigo: { soft: 'bg-indigo-500/12 dark:bg-indigo-400/15', text: 'text-indigo-600 dark:text-indigo-300', dot: 'bg-indigo-500' },
|
||||
sky: { soft: 'bg-sky-500/12 dark:bg-sky-400/15', text: 'text-sky-600 dark:text-sky-300', dot: 'bg-sky-500' },
|
||||
teal: { soft: 'bg-teal-500/12 dark:bg-teal-400/15', text: 'text-teal-600 dark:text-teal-300', dot: 'bg-teal-500' },
|
||||
emerald: { soft: 'bg-emerald-500/12 dark:bg-emerald-400/15', text: 'text-emerald-600 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
lime: { soft: 'bg-lime-500/14 dark:bg-lime-400/15', text: 'text-lime-700 dark:text-lime-300', dot: 'bg-lime-500' },
|
||||
amber: { soft: 'bg-amber-500/14 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
orange: { soft: 'bg-orange-500/12 dark:bg-orange-400/15', text: 'text-orange-600 dark:text-orange-300', dot: 'bg-orange-500' },
|
||||
rose: { soft: 'bg-rose-500/12 dark:bg-rose-400/15', text: 'text-rose-600 dark:text-rose-300', dot: 'bg-rose-500' },
|
||||
pink: { soft: 'bg-pink-500/12 dark:bg-pink-400/15', text: 'text-pink-600 dark:text-pink-300', dot: 'bg-pink-500' },
|
||||
violet: { soft: 'bg-violet-500/12 dark:bg-violet-400/15', text: 'text-violet-600 dark:text-violet-300', dot: 'bg-violet-500' },
|
||||
cyan: { soft: 'bg-cyan-500/12 dark:bg-cyan-400/15', text: 'text-cyan-700 dark:text-cyan-300', dot: 'bg-cyan-500' },
|
||||
slate: { soft: 'bg-slate-500/12 dark:bg-slate-400/15', text: 'text-slate-600 dark:text-slate-300', dot: 'bg-slate-500' },
|
||||
};
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
banknote: Banknote,
|
||||
award: Award,
|
||||
percent: Percent,
|
||||
'circle-dollar-sign': CircleDollarSign,
|
||||
house: House,
|
||||
shield: Shield,
|
||||
landmark: Landmark,
|
||||
wrench: Wrench,
|
||||
sofa: Sofa,
|
||||
zap: Zap,
|
||||
droplets: Droplets,
|
||||
wifi: Wifi,
|
||||
smartphone: Smartphone,
|
||||
'shopping-cart': ShoppingCart,
|
||||
utensils: Utensils,
|
||||
coffee: Coffee,
|
||||
fuel: Fuel,
|
||||
car: Car,
|
||||
'shield-check': ShieldCheck,
|
||||
'car-front': CarFront,
|
||||
'circle-parking': CircleParking,
|
||||
shirt: Shirt,
|
||||
'spray-can': SprayCan,
|
||||
'monitor-smartphone': MonitorSmartphone,
|
||||
'shopping-bag': ShoppingBag,
|
||||
stethoscope: Stethoscope,
|
||||
pill: Pill,
|
||||
sparkles: Sparkles,
|
||||
dumbbell: Dumbbell,
|
||||
ticket: Ticket,
|
||||
tv: Tv,
|
||||
palette: Palette,
|
||||
plane: Plane,
|
||||
baby: Baby,
|
||||
'paw-print': PawPrint,
|
||||
gift: Gift,
|
||||
'heart-handshake': HeartHandshake,
|
||||
receipt: Receipt,
|
||||
scale: Scale,
|
||||
'credit-card': CreditCard,
|
||||
'arrow-left-right': ArrowLeftRight,
|
||||
};
|
||||
|
||||
export function CategoryIcon({
|
||||
icon,
|
||||
color,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
icon: string | undefined;
|
||||
color: PaletteColor | undefined;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}) {
|
||||
const Icon = (icon && ICONS[icon]) || Circle;
|
||||
const tone = PALETTE_CLASS[color ?? 'slate'];
|
||||
const box = size === 'sm' ? 'size-7 rounded-lg [&_svg]:size-[15px]' : size === 'lg' ? 'size-12 rounded-2xl [&_svg]:size-6' : 'size-9 rounded-xl [&_svg]:size-[18px]';
|
||||
return (
|
||||
<span className={cn('flex shrink-0 items-center justify-center', box, tone.soft, tone.text, className)} aria-hidden>
|
||||
<Icon strokeWidth={1.9} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An amount with money in shown in green and a plus sign; money out plain. */
|
||||
export function Amount({ cents, className, transfer }: { cents: number; className?: string; transfer?: boolean }) {
|
||||
return (
|
||||
<span className={cn('tabular whitespace-nowrap font-medium', cents > 0 && !transfer && 'text-good', transfer && 'text-muted-foreground', className)}>
|
||||
{formatCents(cents, { sign: cents > 0 })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountDot({ color, className }: { color: PaletteColor; className?: string }) {
|
||||
return <span className={cn('inline-block size-2 shrink-0 rounded-full', PALETTE_CLASS[color].dot, className)} aria-hidden />;
|
||||
}
|
||||
|
||||
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
const shortFormat = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
||||
|
||||
/** "2026-09-03" → "Thu, Sep 3" (or "Sep 3"), read as a calendar day, never shifted by time zones. */
|
||||
export function formatDay(date: string, short = false): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
return (short ? shortFormat : dayFormat).format(new Date(y!, m! - 1, d!));
|
||||
}
|
||||
21
web/src/components/nav.ts
Normal file
21
web/src/components/nav.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ChartColumnBig, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Shown on a phone's bottom bar; the rest live under More. */
|
||||
phone?: boolean;
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: House, phone: true },
|
||||
{ to: '/review', label: 'Review', icon: Inbox, phone: true },
|
||||
{ to: '/activity', label: 'Activity', icon: ListOrdered, phone: true },
|
||||
{ to: '/budget', label: 'Budget', icon: Target, phone: true },
|
||||
{ to: '/subscriptions', label: 'Subscriptions', icon: Repeat },
|
||||
{ to: '/insights', label: 'Insights', icon: ChartColumnBig },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Landmark },
|
||||
];
|
||||
|
||||
export const SETTINGS_NAV: NavItem = { to: '/settings', label: 'Settings', icon: Settings };
|
||||
63
web/src/components/page.tsx
Normal file
63
web/src/components/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from './ui/misc';
|
||||
|
||||
export function PageIntro({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
titleOnPhone = true,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** Main screens already show their name in the phone's top bar. */
|
||||
titleOnPhone?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="grid gap-1">
|
||||
<h2 className={cn('text-[22px] font-semibold tracking-tight sm:text-2xl', !titleOnPhone && 'hidden sm:block')}>{title}</h2>
|
||||
{description ? <p className="max-w-2xl text-[14.5px] leading-relaxed text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A considered placeholder: what this screen will do, and when it arrives. */
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
milestone,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
milestone?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('relative overflow-hidden rounded-3xl border bg-card px-6 py-14 text-center shadow-soft sm:py-20', className)}>
|
||||
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-56 opacity-70" aria-hidden />
|
||||
<div className="relative mx-auto flex max-w-md flex-col items-center">
|
||||
<span className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-accent text-accent-foreground shadow-soft">
|
||||
<Icon className="size-7" strokeWidth={1.7} />
|
||||
</span>
|
||||
{milestone ? (
|
||||
<Badge tone="primary" className="mb-3">
|
||||
{milestone}
|
||||
</Badge>
|
||||
) : null}
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>
|
||||
{children ? <div className="mt-6">{children}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
web/src/components/ui/button.tsx
Normal file
51
web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Slot } from 'radix-ui';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-[color,background-color,box-shadow,transform] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-soft hover:bg-primary/90',
|
||||
secondary: 'bg-muted text-foreground hover:bg-muted/70',
|
||||
outline: 'border bg-card text-foreground shadow-soft hover:bg-muted',
|
||||
ghost: 'text-foreground hover:bg-muted',
|
||||
destructive: 'bg-bad text-white shadow-soft hover:bg-bad/90',
|
||||
link: 'h-auto px-0 text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-11 px-4 sm:h-10',
|
||||
sm: 'h-9 px-3 text-[13px]',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps extends ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, asChild, loading, disabled, children, ...props }: ButtonProps) {
|
||||
const classes = cn(buttonVariants({ variant, size }), className);
|
||||
// A slotted button styles its single child (usually a link) and adds nothing to it.
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot.Root className={classes} {...props}>
|
||||
{children}
|
||||
</Slot.Root>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className={classes} disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2 className="animate-spin" aria-hidden /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
web/src/components/ui/card.tsx
Normal file
26
web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Card({ className, ...props }: ComponentProps<'section'>) {
|
||||
return <section className={cn('rounded-2xl border bg-card text-card-foreground shadow-soft', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1 p-5 pb-3 sm:p-6 sm:pb-3', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: ComponentProps<'h2'>) {
|
||||
return <h2 className={cn('text-[15px] font-semibold tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: ComponentProps<'p'>) {
|
||||
return <p className={cn('text-[13.5px] leading-relaxed text-muted-foreground', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: ComponentProps<'div'>) {
|
||||
return <div className={cn('px-5 pb-5 sm:px-6 sm:pb-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: ComponentProps<'div'>) {
|
||||
return <div className={cn('flex items-center gap-2 border-t px-5 py-3.5 sm:px-6', className)} {...props} />;
|
||||
}
|
||||
104
web/src/components/ui/dialog.tsx
Normal file
104
web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { AlertDialog as AlertPrimitive, Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
// On phones a dialog rises from the bottom like a native sheet; on larger
|
||||
// screens it floats in the middle.
|
||||
|
||||
const overlayClass = 'fixed inset-0 z-50 bg-night/40 backdrop-blur-[2px] data-[state=open]:animate-[fade-up_.2s_ease]';
|
||||
const contentClass =
|
||||
'fixed z-50 w-full border bg-popover text-popover-foreground shadow-lift outline-none ' +
|
||||
'inset-x-0 bottom-0 max-h-[92dvh] overflow-y-auto rounded-t-3xl px-5 pt-3 pb-safe animate-sheet-up ' +
|
||||
'sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-w-md sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-6 sm:animate-fade-up';
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export function DialogContent({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
...props
|
||||
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; description?: ReactNode }) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className={overlayClass} />
|
||||
<DialogPrimitive.Content className={cn(contentClass, className)} {...props}>
|
||||
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-muted sm:hidden" aria-hidden />
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="grid gap-1">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">{title}</DialogPrimitive.Title>
|
||||
{description ? (
|
||||
<DialogPrimitive.Description className="text-[13.5px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
) : (
|
||||
<DialogPrimitive.Description className="sr-only">{title}</DialogPrimitive.Description>
|
||||
)}
|
||||
</div>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="-mr-2 -mt-1 size-9 text-muted-foreground" aria-label="Close">
|
||||
<X />
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Are you sure?" with a clear consequence and a single confirming action. */
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
destructive,
|
||||
loading,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AlertPrimitive.Portal>
|
||||
<AlertPrimitive.Overlay className={overlayClass} />
|
||||
<AlertPrimitive.Content className={contentClass}>
|
||||
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-muted sm:hidden" aria-hidden />
|
||||
<AlertPrimitive.Title className="text-lg font-semibold tracking-tight">{title}</AlertPrimitive.Title>
|
||||
<AlertPrimitive.Description className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</AlertPrimitive.Description>
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<AlertPrimitive.Cancel asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</AlertPrimitive.Cancel>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
loading={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertPrimitive.Content>
|
||||
</AlertPrimitive.Portal>
|
||||
</AlertPrimitive.Root>
|
||||
);
|
||||
}
|
||||
64
web/src/components/ui/form.tsx
Normal file
64
web/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Label as LabelPrimitive } from 'radix-ui';
|
||||
import { useId, useState, type ComponentProps, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const fieldClass =
|
||||
'w-full rounded-xl border border-input bg-card px-3.5 text-[16px] text-foreground shadow-[inset_0_1px_1px_rgb(0_0_0/0.03)] outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15 disabled:opacity-60 sm:text-sm';
|
||||
|
||||
export function Input({ className, ...props }: ComponentProps<'input'>) {
|
||||
return <input className={cn(fieldClass, 'h-11 sm:h-10', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
|
||||
return <textarea className={cn(fieldClass, 'min-h-28 resize-y py-2.5 leading-relaxed', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Select({ className, children, ...props }: ComponentProps<'select'>) {
|
||||
return (
|
||||
<select className={cn(fieldClass, 'h-11 appearance-none bg-[length:16px] pr-9 sm:h-10', 'select-chevron', className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Label({ className, ...props }: ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return <LabelPrimitive.Root className={cn('text-[13px] font-medium text-foreground', className)} {...props} />;
|
||||
}
|
||||
|
||||
/** A label, a control and an optional hint, wired together for screen readers. */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: ReactNode;
|
||||
children: (id: string) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children(id)}
|
||||
{hint ? <p className="text-[12.5px] leading-snug text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordInput(props: Omit<ComponentProps<'input'>, 'type'>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input type={visible ? 'text' : 'password'} className="pr-11" {...props} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
web/src/components/ui/menu.tsx
Normal file
42
web/src/components/ui/menu.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DropdownMenu as Menu } from 'radix-ui';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const DropdownMenu = Menu.Root;
|
||||
export const DropdownMenuTrigger = Menu.Trigger;
|
||||
|
||||
export function DropdownMenuContent({ className, sideOffset = 8, ...props }: ComponentProps<typeof Menu.Content>) {
|
||||
return (
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-52 animate-fade-up overflow-hidden rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lift',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Menu.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownMenuItem({ className, destructive, ...props }: ComponentProps<typeof Menu.Item> & { destructive?: boolean }) {
|
||||
return (
|
||||
<Menu.Item
|
||||
className={cn(
|
||||
'flex h-10 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2.5 text-[14px] outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-50 sm:h-9 [&_svg]:size-4 [&_svg]:text-muted-foreground',
|
||||
destructive && 'text-bad [&_svg]:text-bad',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownMenuLabel({ className, ...props }: ComponentProps<typeof Menu.Label>) {
|
||||
return <Menu.Label className={cn('px-2.5 py-2', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof Menu.Separator>) {
|
||||
return <Menu.Separator className={cn('-mx-1.5 my-1.5 h-px bg-border', className)} {...props} />;
|
||||
}
|
||||
90
web/src/components/ui/misc.tsx
Normal file
90
web/src/components/ui/misc.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Switch as SwitchPrimitive, Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium', {
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: 'bg-muted text-muted-foreground',
|
||||
primary: 'bg-accent text-accent-foreground',
|
||||
good: 'bg-good/12 text-good',
|
||||
warn: 'bg-warn/14 text-warn',
|
||||
bad: 'bg-bad/12 text-bad',
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: 'neutral' },
|
||||
});
|
||||
|
||||
export function Badge({ className, tone, ...props }: ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors data-[state=checked]:bg-primary disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb className="pointer-events-none block size-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-[18px]" />
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
|
||||
return (
|
||||
<TooltipPrimitive.Root delayDuration={300}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
sideOffset={6}
|
||||
className="z-50 animate-fade-up rounded-lg bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-lift"
|
||||
>
|
||||
{label}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ className, ...props }: ComponentProps<'div'>) {
|
||||
return <div className={cn('animate-pulse rounded-lg bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
/** A row of choices where exactly one is on, like Light / Dark / System. */
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: { value: T; label: string; icon?: ReactNode }[];
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label} className="inline-flex rounded-xl bg-muted p-1">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'flex h-9 items-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium text-muted-foreground transition-all [&_svg]:size-4',
|
||||
value === o.value && 'bg-card text-foreground shadow-soft',
|
||||
)}
|
||||
>
|
||||
{o.icon}
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
web/src/lib/api.ts
Normal file
39
web/src/lib/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ApiErrorBody } from '@spendlight/shared';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
/** Call the Spendlight API. Errors arrive as ApiError with a friendly message. */
|
||||
export async function api<T>(path: string, options: { method?: Method; body?: unknown } = {}): Promise<T> {
|
||||
const { method = 'GET', body } = options;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, 'offline', 'Can’t reach Spendlight right now. Check your connection and try again.');
|
||||
}
|
||||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||
const data = isJson ? await res.json() : await res.text();
|
||||
if (!res.ok) {
|
||||
const error = (isJson ? (data as ApiErrorBody).error : undefined) ?? { code: 'error', message: res.statusText };
|
||||
throw new ApiError(res.status, error.code, error.message);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : 'Something went wrong. Please try again.';
|
||||
50
web/src/lib/data.ts
Normal file
50
web/src/lib/data.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Account, Category, CategoryGroup } from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { api } from './api';
|
||||
|
||||
export function useAccounts() {
|
||||
return useQuery({ queryKey: ['accounts'], queryFn: () => api<Account[]>('/accounts') });
|
||||
}
|
||||
|
||||
export function useCategories() {
|
||||
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories'), staleTime: 5 * 60_000 });
|
||||
const lookup = useMemo(() => {
|
||||
const all: Category[] = query.data?.flatMap((g) => g.categories) ?? [];
|
||||
return { all, byId: new Map(all.map((c) => [c.id, c])) };
|
||||
}, [query.data]);
|
||||
return { ...query, ...lookup };
|
||||
}
|
||||
|
||||
export function useReviewCount() {
|
||||
return useQuery({
|
||||
queryKey: ['review-count'],
|
||||
queryFn: () => api<{ count: number }>('/review/count'),
|
||||
select: (d) => d.count,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery({ queryKey: ['tags'], queryFn: () => api<{ name: string; count: number }[]>('/tags') });
|
||||
}
|
||||
|
||||
/** Everything that changes when transactions change. */
|
||||
export const MONEY_KEYS = [['transactions'], ['review'], ['review-count'], ['accounts'], ['imports'], ['tags']] as const;
|
||||
|
||||
/** Remember recent category picks on this device, for the top of the picker. */
|
||||
const RECENT_KEY = 'sl-recent-categories';
|
||||
export function recentCategories(): number[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as number[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function rememberCategory(id: number): void {
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...recentCategories().filter((x) => x !== id)].slice(0, 6)));
|
||||
} catch {
|
||||
/* fine without it */
|
||||
}
|
||||
}
|
||||
20
web/src/lib/pendingFiles.ts
Normal file
20
web/src/lib/pendingFiles.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Files dropped anywhere in the app wait here until the Import screen picks them up.
|
||||
|
||||
let pending: File[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function queueFiles(files: File[]): void {
|
||||
pending = [...pending, ...files];
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function takeFiles(): File[] {
|
||||
const files = pending;
|
||||
pending = [];
|
||||
return files;
|
||||
}
|
||||
|
||||
export function onFilesQueued(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
57
web/src/lib/session.ts
Normal file
57
web/src/lib/session.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Me, PublicConfig } from '@spendlight/shared';
|
||||
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { api, ApiError } from './api';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: (count, error) => !(error instanceof ApiError && error.status >= 400 && error.status < 500) && count < 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function useConfig() {
|
||||
return useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => api<PublicConfig>('/config'),
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** The person signed in on this device, or null when signed out. */
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api<Me>('/me');
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Me, for components that only render once someone is signed in. */
|
||||
export function useCurrentUser(): Me {
|
||||
const { data } = useMe();
|
||||
if (!data) throw new Error('useCurrentUser used outside a signed-in screen');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function useSignOut() {
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: () => api('/auth/logout', { method: 'POST' }),
|
||||
onSettled: () => {
|
||||
client.setQueryData(['me'], null);
|
||||
client.removeQueries({ predicate: (q) => q.queryKey[0] !== 'me' && q.queryKey[0] !== 'config' });
|
||||
navigate('/sign-in', { replace: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
38
web/src/lib/theme.ts
Normal file
38
web/src/lib/theme.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Theme } from '@spendlight/shared';
|
||||
|
||||
const KEY = 'sl-theme';
|
||||
const media = () => window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
export function storedTheme(): Theme {
|
||||
try {
|
||||
const value = localStorage.getItem(KEY);
|
||||
return value === 'light' || value === 'dark' ? value : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a theme now and remember it on this device for an instant first paint. */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
/* private mode: the server copy still applies */
|
||||
}
|
||||
const dark = theme === 'dark' || (theme === 'system' && media().matches);
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', dark);
|
||||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||
document
|
||||
.querySelectorAll('meta[name="theme-color"]')
|
||||
.forEach((m) => m.setAttribute('content', dark ? '#0B1020' : '#FAFAF7'));
|
||||
}
|
||||
|
||||
/** Follow the device's light/dark switch while the choice is "system". */
|
||||
export function watchSystemTheme(): () => void {
|
||||
const listener = () => {
|
||||
if (storedTheme() === 'system') applyTheme('system');
|
||||
};
|
||||
media().addEventListener('change', listener);
|
||||
return () => media().removeEventListener('change', listener);
|
||||
}
|
||||
15
web/src/lib/useMediaQuery.ts
Normal file
15
web/src/lib/useMediaQuery.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => {
|
||||
const list = window.matchMedia(query);
|
||||
list.addEventListener('change', onChange);
|
||||
return () => list.removeEventListener('change', onChange);
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
);
|
||||
}
|
||||
|
||||
/** Phones get bottom sheets and big targets; larger screens get popovers. */
|
||||
export const useIsPhone = () => !useMediaQuery('(min-width: 640px)');
|
||||
15
web/src/lib/useSetTheme.ts
Normal file
15
web/src/lib/useSetTheme.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Me, Theme } from '@spendlight/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from './api';
|
||||
import { applyTheme } from './theme';
|
||||
|
||||
/** Switch theme instantly, then save it to the person's profile. */
|
||||
export function useSetTheme() {
|
||||
const client = useQueryClient();
|
||||
return (theme: Theme) => {
|
||||
applyTheme(theme);
|
||||
client.setQueryData<Me | null>(['me'], (me) => (me ? { ...me, theme } : me));
|
||||
api<Me>('/me', { method: 'PATCH', body: { theme } }).catch((error) => toast.error(errorMessage(error)));
|
||||
};
|
||||
}
|
||||
64
web/src/lib/utils.ts
Normal file
64
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
return ((parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '')).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||
|
||||
/** "just now", "5 minutes ago", "yesterday", "3 weeks ago". */
|
||||
export function timeAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return 'never';
|
||||
const seconds = (Date.parse(iso) - Date.now()) / 1000;
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs < 60) return 'just now';
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
['minute', 60],
|
||||
['hour', 3600],
|
||||
['day', 86400],
|
||||
['week', 604800],
|
||||
['month', 2629800],
|
||||
['year', 31557600],
|
||||
];
|
||||
let unit: Intl.RelativeTimeFormatUnit = 'minute';
|
||||
let size = 60;
|
||||
for (const [u, s] of units) if (abs >= s) [unit, size] = [u, s];
|
||||
return relative.format(Math.round(seconds / size), unit);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function isIos(): boolean {
|
||||
return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
2
web/src/lib/version.ts
Normal file
2
web/src/lib/version.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** The version this copy of the screens was built as, e.g. 20261007.01. */
|
||||
export const APP_VERSION: string = import.meta.env.VITE_APP_VERSION || 'dev';
|
||||
35
web/src/main.tsx
Normal file
35
web/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import '@fontsource-variable/inter';
|
||||
import './styles.css';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Tooltip } from 'radix-ui';
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router/dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Splash } from './components/RequireAuth';
|
||||
import { queryClient } from './lib/session';
|
||||
import { watchSystemTheme } from './lib/theme';
|
||||
import { router } from './router';
|
||||
|
||||
watchSystemTheme();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Tooltip.Provider>
|
||||
<Suspense fallback={<Splash />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<Toaster position="top-center" richColors closeButton theme="system" toastOptions={{ className: 'font-sans' }} />
|
||||
</Tooltip.Provider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
/* the app works fine without it */
|
||||
});
|
||||
});
|
||||
}
|
||||
453
web/src/pages/AccountsPage.tsx
Normal file
453
web/src/pages/AccountsPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { ACCOUNT_KIND_LABEL, LIABILITY_KINDS, PALETTE, formatCents, type Account, type AccountKind, type PaletteColor } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, ArchiveRestore, ChevronRight, CreditCard, Landmark, ListOrdered, Pencil, Plus, Scale, Trash2, Upload, Wallet } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { AccountDot, PALETTE_CLASS } from '@/components/money';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn, timeAgo } from '@/lib/utils';
|
||||
import { formatDay } from '@/components/money';
|
||||
|
||||
const isLiability = (kind: AccountKind) => LIABILITY_KINDS.includes(kind);
|
||||
const today = () => new Date().toLocaleDateString('en-CA');
|
||||
|
||||
/** "$1,234.56" typed by a person → cents, or null. */
|
||||
function parseMoney(text: string): number | null {
|
||||
const clean = text.replace(/[$,\s]/g, '');
|
||||
if (!/^-?\d+(\.\d{0,2})?$/.test(clean)) return null;
|
||||
return Math.round(Number(clean) * 100);
|
||||
}
|
||||
|
||||
function ColorChoice({ value, onChange }: { value: PaletteColor; onChange: (c: PaletteColor) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Color">
|
||||
{PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === c}
|
||||
aria-label={c}
|
||||
onClick={() => onChange(c)}
|
||||
className={cn('size-8 rounded-full ring-offset-2 ring-offset-popover transition', PALETTE_CLASS[c].dot, value === c && 'ring-2 ring-foreground')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountForm({ account, onDone }: { account?: Account; onDone: (a: Account) => void }) {
|
||||
const [name, setName] = useState(account?.name ?? '');
|
||||
const [kind, setKind] = useState<AccountKind>(account?.kind ?? 'checking');
|
||||
const [institution, setInstitution] = useState(account?.institution ?? '');
|
||||
const [last4, setLast4] = useState(account?.last4 ?? '');
|
||||
const [color, setColor] = useState<PaletteColor>(account?.color ?? 'indigo');
|
||||
const [balance, setBalance] = useState('');
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: Record<string, unknown> = { name, kind, institution: institution || null, last4: last4 || null, color };
|
||||
if (!account && balance.trim()) {
|
||||
const cents = parseMoney(balance);
|
||||
if (cents === null) throw new Error('Please enter the balance as a number, like 1250.00');
|
||||
body.balanceCents = isLiability(kind) ? -Math.abs(cents) : cents;
|
||||
}
|
||||
return account ? api<Account>(`/accounts/${account.id}`, { method: 'PATCH', body }) : api<Account>('/accounts', { method: 'POST', body });
|
||||
},
|
||||
onSuccess: onDone,
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Field label="Name" hint="Whatever you call it, like “Joint checking” or “Prime Visa”.">
|
||||
{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required maxLength={60} autoFocus={!account} />}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Kind">
|
||||
{(id) => (
|
||||
<Select id={id} value={kind} onChange={(e) => setKind(e.target.value as AccountKind)}>
|
||||
{(['checking', 'savings', 'credit_card', 'cash'] as AccountKind[]).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{ACCOUNT_KIND_LABEL[k]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Last 4 digits">
|
||||
{(id) => (
|
||||
<Input id={id} value={last4} onChange={(e) => setLast4(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" placeholder="1234" />
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Bank">
|
||||
{(id) => <Input id={id} value={institution} onChange={(e) => setInstitution(e.target.value)} placeholder="Bank of America" maxLength={60} />}
|
||||
</Field>
|
||||
{!account ? (
|
||||
<Field
|
||||
label={isLiability(kind) ? 'Amount owed today' : 'Balance today'}
|
||||
hint={isLiability(kind) ? 'From your card’s app or statement. Optional; you can add it later.' : 'Optional. Bank of America files fill this in by themselves.'}
|
||||
>
|
||||
{(id) => <Input id={id} value={balance} onChange={(e) => setBalance(e.target.value)} inputMode="decimal" placeholder="0.00" />}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid gap-2">
|
||||
<span className="text-[13px] font-medium">Color</span>
|
||||
<ColorChoice value={color} onChange={setColor} />
|
||||
</div>
|
||||
<Button type="submit" loading={save.isPending} className="mt-1">
|
||||
{account ? 'Save' : 'Add account'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceForm({ account, onDone }: { account: Account; onDone: () => void }) {
|
||||
const liability = isLiability(account.kind);
|
||||
const [amount, setAmount] = useState(((liability ? -account.balanceCents : account.balanceCents) / 100).toFixed(2));
|
||||
const [asOf, setAsOf] = useState(today());
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const cents = parseMoney(amount);
|
||||
if (cents === null) throw new Error('Please enter the balance as a number, like 1250.00');
|
||||
return api<Account>(`/accounts/${account.id}/balance`, { method: 'POST', body: { balanceCents: liability ? -Math.abs(cents) : cents, asOf } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Balance updated');
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Field label={liability ? 'Amount owed' : 'Balance'} hint="Use the ending balance on your latest statement, or today’s from the bank’s app.">
|
||||
{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" required />}
|
||||
</Field>
|
||||
<Field label="As of">{(id) => <Input id={id} type="date" value={asOf} onChange={(e) => setAsOf(e.target.value)} required />}</Field>
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
Save balance
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card's balance means nothing until someone has entered it once. */
|
||||
const needsBalance = (a: Account) => isLiability(a.kind) && !a.balanceConfirmedOn;
|
||||
|
||||
function AccountRow({ account, onOpen }: { account: Account; onOpen: () => void }) {
|
||||
const liability = isLiability(account.kind);
|
||||
const missing = needsBalance(account);
|
||||
return (
|
||||
<button onClick={onOpen} className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5">
|
||||
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', PALETTE_CLASS[account.color].soft, PALETTE_CLASS[account.color].text)}>
|
||||
{account.kind === 'credit_card' ? <CreditCard /> : account.kind === 'cash' ? <Wallet /> : <Landmark />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-medium">{account.name}</p>
|
||||
<p className="truncate text-[12.5px] text-muted-foreground">
|
||||
{[account.institution, account.last4 ? `••${account.last4}` : null].filter(Boolean).join(' ') || ACCOUNT_KIND_LABEL[account.kind]}
|
||||
{account.lastImportAt ? ` · imported ${timeAgo(account.lastImportAt)}` : account.transactionCount === 0 ? ' · no statements yet' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{missing ? (
|
||||
<p className="text-[13px] font-medium text-primary">Add balance</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="tabular text-[15px] font-semibold">{formatCents(liability ? -account.balanceCents : account.balanceCents)}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">{liability ? 'owed' : 'balance'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const client = useQueryClient();
|
||||
const accounts = useAccounts();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'balance'>('view');
|
||||
const [confirm, setConfirm] = useState<'retire' | 'delete' | null>(null);
|
||||
const [showRetired, setShowRetired] = useState(false);
|
||||
const refresh = () => void client.invalidateQueries({ queryKey: ['accounts'] });
|
||||
|
||||
const all = accounts.data ?? [];
|
||||
const open = all.filter((a) => a.status === 'open');
|
||||
const retired = all.filter((a) => a.status === 'retired');
|
||||
const selected = all.find((a) => a.id === openId) ?? null;
|
||||
const cash = open.filter((a) => !isLiability(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
|
||||
const owed = open.filter((a) => isLiability(a.kind) && !needsBalance(a)).reduce((s, a) => s - a.balanceCents, 0);
|
||||
const cardsWithoutBalance = open.filter(needsBalance).length;
|
||||
|
||||
const setStatus = useMutation({
|
||||
mutationFn: (status: 'open' | 'retired') => api<Account>(`/accounts/${openId}`, { method: 'PATCH', body: { status } }),
|
||||
onSuccess: (a) => {
|
||||
toast.success(a.status === 'retired' ? `${a.name} retired. Its history stays in your reports.` : `${a.name} reopened`);
|
||||
setConfirm(null);
|
||||
setOpenId(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api(`/accounts/${openId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Account deleted');
|
||||
setConfirm(null);
|
||||
setOpenId(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const groups: { title: string; items: Account[] }[] = [
|
||||
{ title: 'Checking, savings and cash', items: open.filter((a) => !isLiability(a.kind)) },
|
||||
{ title: 'Cards and loans', items: open.filter((a) => isLiability(a.kind)) },
|
||||
].filter((g) => g.items.length);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Accounts"
|
||||
titleOnPhone={false}
|
||||
description="Every account you track, with balances kept current from your statements."
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/import">
|
||||
<Upload /> Import
|
||||
</Link>
|
||||
</Button>
|
||||
<Button onClick={() => setAdding(true)}>
|
||||
<Plus /> Add account
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{accounts.isPending ? <Skeleton className="h-40 rounded-2xl" /> : null}
|
||||
{accounts.data && open.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Landmark}
|
||||
title="Add your first account"
|
||||
description="Checking, savings and each card you use. Or just import a statement and Spendlight will offer to create the account for you."
|
||||
>
|
||||
{canEdit ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button onClick={() => setAdding(true)}>
|
||||
<Plus /> Add account
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/import">
|
||||
<Upload /> Import a statement
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : null}
|
||||
|
||||
{open.length ? (
|
||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||
<Card className="p-4 sm:p-5">
|
||||
<p className="text-[12.5px] font-medium text-muted-foreground">Cash and savings</p>
|
||||
<p className="tabular mt-1 text-[22px] font-semibold tracking-tight sm:text-2xl">{formatCents(cash)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 sm:p-5">
|
||||
<p className="text-[12.5px] font-medium text-muted-foreground">Owed on cards</p>
|
||||
<p className="tabular mt-1 text-[22px] font-semibold tracking-tight sm:text-2xl">{formatCents(owed)}</p>
|
||||
{cardsWithoutBalance ? (
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
{cardsWithoutBalance} card{cardsWithoutBalance === 1 ? '' : 's'} still need a balance
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5">
|
||||
{groups.map((g) => (
|
||||
<section key={g.title}>
|
||||
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">{g.title}</h3>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{g.items.map((a) => (
|
||||
<AccountRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
onOpen={() => {
|
||||
setMode('view');
|
||||
setOpenId(a.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
{retired.length ? (
|
||||
<section>
|
||||
<button onClick={() => setShowRetired((v) => !v)} className="mb-2 px-1 text-[13px] font-medium text-muted-foreground hover:text-foreground">
|
||||
{showRetired ? 'Hide' : 'Show'} {retired.length} retired account{retired.length === 1 ? '' : 's'}
|
||||
</button>
|
||||
{showRetired ? (
|
||||
<Card className="divide-y overflow-hidden opacity-75">
|
||||
{retired.map((a) => (
|
||||
<AccountRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
onOpen={() => {
|
||||
setMode('view');
|
||||
setOpenId(a.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={adding} onOpenChange={setAdding}>
|
||||
<DialogContent title="Add an account" description="You can import its statements straight afterwards.">
|
||||
<AccountForm
|
||||
onDone={(a) => {
|
||||
toast.success(`${a.name} added`);
|
||||
setAdding(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!selected && !confirm} onOpenChange={(o) => !o && setOpenId(null)}>
|
||||
{selected ? (
|
||||
<DialogContent
|
||||
title={mode === 'edit' ? 'Edit account' : mode === 'balance' ? 'Correct the balance' : selected.name}
|
||||
description={
|
||||
mode === 'view'
|
||||
? [ACCOUNT_KIND_LABEL[selected.kind], selected.institution, selected.last4 ? `••${selected.last4}` : null].filter(Boolean).join(' · ')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{mode === 'edit' ? (
|
||||
<AccountForm
|
||||
account={selected}
|
||||
onDone={() => {
|
||||
toast.success('Saved');
|
||||
setMode('view');
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : mode === 'balance' ? (
|
||||
<BalanceForm
|
||||
account={selected}
|
||||
onDone={() => {
|
||||
setMode('view');
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-2xl bg-muted p-4">
|
||||
<div className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
|
||||
<AccountDot color={selected.color} /> {isLiability(selected.kind) ? 'Owed' : 'Balance'}
|
||||
</div>
|
||||
<p className="tabular mt-1 text-[26px] font-semibold tracking-tight">
|
||||
{formatCents(isLiability(selected.kind) ? -selected.balanceCents : selected.balanceCents)}
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{selected.balanceConfirmedOn ? `Confirmed on ${formatDay(selected.balanceConfirmedOn)}, plus anything since` : 'No balance confirmed yet'}
|
||||
{` · ${selected.transactionCount} transaction${selected.transactionCount === 1 ? '' : 's'}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{needsBalance(selected) && canEdit ? (
|
||||
<Button onClick={() => setMode('balance')}>
|
||||
<Scale /> Add what you owe today
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/activity?accountId=${selected.id}`}>
|
||||
<ListOrdered /> See transactions
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setMode('balance')}>
|
||||
<Scale /> Correct the balance
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setMode('edit')}>
|
||||
<Pencil /> Edit name, bank or color
|
||||
</Button>
|
||||
{selected.status === 'open' ? (
|
||||
<Button variant="ghost" onClick={() => setConfirm('retire')}>
|
||||
<Archive /> Retire (closed account)
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" loading={setStatus.isPending} onClick={() => setStatus.mutate('open')}>
|
||||
<ArchiveRestore /> Reopen
|
||||
</Button>
|
||||
)}
|
||||
{selected.transactionCount === 0 ? (
|
||||
<Button variant="ghost" className="text-bad hover:text-bad" onClick={() => setConfirm('delete')}>
|
||||
<Trash2 /> Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirm === 'retire'}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
title={`Retire ${selected?.name ?? ''}?`}
|
||||
description="It stops appearing in pickers and imports. Every past transaction stays in your history and reports, and you can reopen it any time."
|
||||
confirmLabel="Retire"
|
||||
loading={setStatus.isPending}
|
||||
onConfirm={() => setStatus.mutate('retired')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirm === 'delete'}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
title={`Delete ${selected?.name ?? ''}?`}
|
||||
description="It has no transactions, so nothing else is affected."
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
web/src/pages/ActivityPage.tsx
Normal file
362
web/src/pages/ActivityPage.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { formatCents, type Transaction, type TransactionPage } from '@spendlight/shared';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeftRight, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryButton, CategoryPicker } from '@/components/CategoryPicker';
|
||||
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { TransactionSheet, useRefreshMoney } from '@/components/TransactionSheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { Badge, Segmented, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts, useCategories } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PAGE = 100;
|
||||
const iso = (d: Date) => d.toLocaleDateString('en-CA');
|
||||
|
||||
function periodRange(period: string): { from?: string; to?: string } {
|
||||
const now = new Date();
|
||||
if (period === 'month') return { from: iso(new Date(now.getFullYear(), now.getMonth(), 1)) };
|
||||
if (period === 'last') {
|
||||
return { from: iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)), to: iso(new Date(now.getFullYear(), now.getMonth(), 0)) };
|
||||
}
|
||||
if (period === 'year') return { from: iso(new Date(now.getFullYear(), 0, 1)) };
|
||||
return {};
|
||||
}
|
||||
|
||||
function CashEntry({ onDone }: { onDone: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const open = accounts.data?.filter((a) => a.status === 'open') ?? [];
|
||||
const [accountId, setAccountId] = useState<number | ''>(() => open.find((a) => a.kind === 'cash')?.id ?? open[0]?.id ?? '');
|
||||
const [date, setDate] = useState(iso(new Date()));
|
||||
const [merchant, setMerchant] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [direction, setDirection] = useState<'out' | 'in'>('out');
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const refresh = useRefreshMoney();
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const cents = Math.round(Number(amount.replace(/[$,\s]/g, '')) * 100);
|
||||
if (!cents) throw new Error('Please enter the amount, like 24.00');
|
||||
return api<Transaction>('/transactions', {
|
||||
method: 'POST',
|
||||
body: { accountId, date, merchant, amountCents: direction === 'out' ? -Math.abs(cents) : Math.abs(cents), categoryId },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Added');
|
||||
refresh();
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
if (!open.length) return <p className="text-[14px] text-muted-foreground">Add an account first, such as a “Cash” account, in Accounts.</p>;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Segmented
|
||||
label="Money in or out"
|
||||
value={direction}
|
||||
onChange={setDirection}
|
||||
options={[
|
||||
{ value: 'out', label: 'Money out' },
|
||||
{ value: 'in', label: 'Money in' },
|
||||
]}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Amount">{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="0.00" required autoFocus />}</Field>
|
||||
<Field label="Date">{(id) => <Input id={id} type="date" value={date} onChange={(e) => setDate(e.target.value)} required />}</Field>
|
||||
</div>
|
||||
<Field label="Paid to (or from)">{(id) => <Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} placeholder="Farmers market" required maxLength={80} />}</Field>
|
||||
<Field label="Account">
|
||||
{(id) => (
|
||||
<Select id={id} value={accountId} onChange={(e) => setAccountId(Number(e.target.value))}>
|
||||
{open.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Category</span>
|
||||
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} />
|
||||
</div>
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ t, onOpen }: { t: Transaction; onOpen: () => void }) {
|
||||
const { byId } = useCategories();
|
||||
const accounts = useAccounts();
|
||||
const account = accounts.data?.find((a) => a.id === t.accountId);
|
||||
const category = t.categoryId ? byId.get(t.categoryId) : undefined;
|
||||
const transfer = !!t.transferPeer;
|
||||
return (
|
||||
<button onClick={onOpen} className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60 sm:px-5">
|
||||
{t.isSplit ? (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<SplitIcon className="size-[18px]" />
|
||||
</span>
|
||||
) : transfer ? (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<ArrowLeftRight className="size-[18px]" />
|
||||
</span>
|
||||
) : (
|
||||
<CategoryIcon icon={category?.icon} color={category?.color} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('truncate text-[15px] font-medium', transfer && 'italic text-muted-foreground')}>
|
||||
{transfer ? `Transfer ${t.amountCents < 0 ? 'to' : 'from'} ${t.transferPeer!.accountName}` : t.merchant}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
<span className="truncate">
|
||||
{account?.name}
|
||||
{' · '}
|
||||
{transfer ? t.merchant : t.isSplit ? `Split into ${t.splits.length}` : (category?.name ?? 'Not filed yet')}
|
||||
</span>
|
||||
{t.tags.length ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
· <Tag className="size-3" /> {t.tags[0]}
|
||||
{t.tags.length > 1 ? ` +${t.tags.length - 1}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<Amount cents={t.amountCents} transfer={transfer} />
|
||||
{t.needsReview ? <Badge tone="warn">To review</Badge> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const me = useCurrentUser();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const accounts = useAccounts();
|
||||
const { byId } = useCategories();
|
||||
const [search, setSearch] = useState(params.get('q') ?? '');
|
||||
const [showFilters, setShowFilters] = useState(!!(params.get('accountId') || params.get('categoryId') || params.get('tag')));
|
||||
const [limit, setLimit] = useState(PAGE);
|
||||
const [open, setOpen] = useState<Transaction | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const accountId = params.get('accountId') ?? '';
|
||||
const categoryId = params.get('categoryId') ?? '';
|
||||
const tag = params.get('tag') ?? '';
|
||||
const period = params.get('period') ?? 'all';
|
||||
const q = params.get('q') ?? '';
|
||||
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value && value !== 'all') next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
setLimit(PAGE);
|
||||
};
|
||||
|
||||
const query = useMemo(() => {
|
||||
const s = new URLSearchParams();
|
||||
if (q) s.set('q', q);
|
||||
if (accountId) s.set('accountId', accountId);
|
||||
if (categoryId) s.set('categoryId', categoryId);
|
||||
if (tag) s.set('tag', tag);
|
||||
const r = periodRange(period);
|
||||
if (r.from) s.set('from', r.from);
|
||||
if (r.to) s.set('to', r.to);
|
||||
s.set('limit', String(limit));
|
||||
return s.toString();
|
||||
}, [q, accountId, categoryId, tag, period, limit]);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['transactions', query],
|
||||
queryFn: () => api<TransactionPage>(`/transactions?${query}`),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const out: { date: string; items: Transaction[] }[] = [];
|
||||
for (const t of list.data?.items ?? []) {
|
||||
const last = out.at(-1);
|
||||
if (last?.date === t.date) last.items.push(t);
|
||||
else out.push({ date: t.date, items: [t] });
|
||||
}
|
||||
return out;
|
||||
}, [list.data]);
|
||||
|
||||
const filterCount = [accountId, categoryId, tag].filter(Boolean).length;
|
||||
const category = categoryId && categoryId !== 'none' ? byId.get(Number(categoryId)) : undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Activity"
|
||||
titleOnPhone={false}
|
||||
description="Every transaction from every account. Tap one to change its category, split it, tag it or add a note."
|
||||
actions={
|
||||
me.access === 'full' ? (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus /> Cash entry
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 grid gap-3">
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="relative flex-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setParam('q', search.trim());
|
||||
}}
|
||||
>
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={() => search.trim() !== q && setParam('q', search.trim())}
|
||||
placeholder="Search merchants, descriptions and notes"
|
||||
aria-label="Search"
|
||||
className="pl-10"
|
||||
type="search"
|
||||
/>
|
||||
</form>
|
||||
<Button variant={filterCount ? 'default' : 'outline'} onClick={() => setShowFilters((v) => !v)} aria-expanded={showFilters}>
|
||||
<SlidersHorizontal /> <span className="hidden sm:inline">Filters</span>
|
||||
{filterCount ? <span className="tabular">{filterCount}</span> : null}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
|
||||
{[
|
||||
['all', 'All time'],
|
||||
['month', 'This month'],
|
||||
['last', 'Last month'],
|
||||
['year', 'This year'],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setParam('period', value!)}
|
||||
className={cn(
|
||||
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
|
||||
period === value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showFilters ? (
|
||||
<Card className="grid gap-3 p-4 sm:grid-cols-3">
|
||||
<Field label="Account">
|
||||
{(id) => (
|
||||
<Select id={id} value={accountId} onChange={(e) => setParam('accountId', e.target.value)}>
|
||||
<option value="">All accounts</option>
|
||||
{accounts.data?.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Category</span>
|
||||
<div className="flex gap-1.5">
|
||||
<CategoryPicker value={category?.id ?? null} onPick={(c) => setParam('categoryId', String(c.id))}>
|
||||
<button className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-xl border bg-card px-3 text-left text-[14px] sm:h-10">
|
||||
{category ? <CategoryIcon icon={category.icon} color={category.color} size="sm" className="size-6 rounded-md" /> : null}
|
||||
<span className="truncate">{categoryId === 'none' ? 'Not filed yet' : (category?.name ?? 'All categories')}</span>
|
||||
</button>
|
||||
</CategoryPicker>
|
||||
{categoryId ? (
|
||||
<Button variant="ghost" size="icon" aria-label="Clear category" onClick={() => setParam('categoryId', '')}>
|
||||
<X />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-11 sm:h-10" onClick={() => setParam('categoryId', 'none')}>
|
||||
Unfiled
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Tag">
|
||||
{(id) => <Input id={id} defaultValue={tag} onBlur={(e) => setParam('tag', e.target.value.trim())} placeholder="Any tag" />}
|
||||
</Field>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{list.data ? (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 px-1 text-[13px] text-muted-foreground">
|
||||
<span>
|
||||
{list.data.total} transaction{list.data.total === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="tabular">
|
||||
In <span className="font-medium text-good">{formatCents(list.data.sums.inCents)}</span> · Out{' '}
|
||||
<span className="font-medium text-foreground">{formatCents(-list.data.sums.outCents)}</span>
|
||||
<span className="hidden sm:inline"> · transfers not counted</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
|
||||
{list.data && list.data.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={q || filterCount || period !== 'all' ? 'Nothing matches' : 'No transactions yet'}
|
||||
description={q || filterCount || period !== 'all' ? 'Try a different search or clear the filters.' : 'Import a statement and everything shows up here.'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={cn('grid gap-4 transition-opacity', list.isPlaceholderData && 'opacity-60')}>
|
||||
{groups.map((g) => (
|
||||
<section key={g.date}>
|
||||
<h3 className="sticky top-14 z-10 -mx-1 mb-1.5 bg-background/90 px-2 py-1 text-[12.5px] font-semibold text-muted-foreground backdrop-blur">
|
||||
{formatDay(g.date)}
|
||||
</h3>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{g.items.map((t) => (
|
||||
<Row key={t.id} t={t} onOpen={() => setOpen(t)} />
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{list.data && list.data.items.length < list.data.total ? (
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button variant="outline" onClick={() => setLimit((l) => l + PAGE)} loading={list.isFetching}>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TransactionSheet transaction={open} onClose={() => setOpen(null)} />
|
||||
<Dialog open={adding} onOpenChange={setAdding}>
|
||||
<DialogContent title="Add a cash entry" description="For spending that won’t appear on a statement, like cash or a check.">
|
||||
<CashEntry onDone={() => setAdding(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
web/src/pages/HomePage.tsx
Normal file
196
web/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { formatCents, type TransactionPage } from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Target, Upload } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAccounts, useReviewCount } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn, isIos, isStandalone } from '@/lib/utils';
|
||||
|
||||
function greeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
}
|
||||
|
||||
function Step({
|
||||
done,
|
||||
icon,
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
later,
|
||||
}: {
|
||||
done?: boolean;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
detail: ReactNode;
|
||||
action?: ReactNode;
|
||||
later?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex gap-4 py-4 first:pt-1 last:pb-1">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
|
||||
done ? 'bg-good/12 text-good' : later ? 'bg-muted text-muted-foreground' : 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{done ? <Check strokeWidth={2.5} /> : icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
|
||||
{later ? <Badge>{later}</Badge> : null}
|
||||
</div>
|
||||
{!done ? <p className="mt-0.5 text-[13.5px] leading-relaxed text-muted-foreground">{detail}</p> : null}
|
||||
</div>
|
||||
{!done && action ? <div className="shrink-0 self-center">{action}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const me = useCurrentUser();
|
||||
const firstName = me.displayName.split(' ')[0];
|
||||
const installed = isStandalone();
|
||||
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toLocaleDateString('en-CA');
|
||||
const monthName = now.toLocaleDateString(undefined, { month: 'long' });
|
||||
const accounts = useAccounts();
|
||||
const { data: toReview } = useReviewCount();
|
||||
const everything = useQuery({ queryKey: ['transactions', 'home-any'], queryFn: () => api<TransactionPage>('/transactions?limit=1') });
|
||||
// Statements usually arrive after a month ends, so until this month has
|
||||
// transactions, Home shows the most recent month that does.
|
||||
const latest = everything.data?.items[0]?.date;
|
||||
const showStart = latest && latest < monthStart ? `${latest.slice(0, 7)}-01` : monthStart;
|
||||
const showEnd = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)), 0).toLocaleDateString('en-CA');
|
||||
const shownName = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)) - 1, 1).toLocaleDateString(undefined, { month: 'long' });
|
||||
const month = useQuery({
|
||||
queryKey: ['transactions', 'home-month', showStart],
|
||||
queryFn: () => api<TransactionPage>(`/transactions?from=${showStart}&to=${showEnd}&limit=1`),
|
||||
enabled: everything.isSuccess,
|
||||
});
|
||||
const hasAccounts = (accounts.data?.length ?? 0) > 0;
|
||||
const hasMoney = (everything.data?.total ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 sm:gap-6">
|
||||
<section className="relative overflow-hidden rounded-3xl bg-night px-6 dark:bg-card dark:ring-1 dark:ring-border py-8 text-white shadow-lift sm:px-9 sm:py-10">
|
||||
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(251_191_36/0.45),rgb(249_115_98/0.18),transparent)] blur-2xl" aria-hidden />
|
||||
<div className="relative">
|
||||
<p className="text-[13px] font-medium text-white/60">{today}</p>
|
||||
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting()}, {firstName}.
|
||||
</h2>
|
||||
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
|
||||
{hasMoney
|
||||
? showStart === monthStart
|
||||
? `Here’s ${monthName} so far. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
|
||||
: `Here’s ${shownName}, your latest month in Spendlight. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
|
||||
: 'Spendlight is ready for your accounts. Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.'}
|
||||
</p>
|
||||
<div className="mt-7 grid max-w-xl grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Spent', value: month.data ? formatCents(-month.data.sums.outCents, { whole: true }) : null },
|
||||
{ label: 'Money in', value: month.data ? formatCents(month.data.sums.inCents, { whole: true }) : null },
|
||||
{ label: 'To review', value: toReview !== undefined ? String(toReview) : null, to: '/review' },
|
||||
].map(({ label, value, to }) => {
|
||||
const body = (
|
||||
<>
|
||||
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
|
||||
{value !== null && hasMoney ? (
|
||||
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[22px]">{value}</p>
|
||||
) : (
|
||||
<Skeleton className="mt-2 h-6 w-16 bg-white/10" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return to && hasMoney ? (
|
||||
<Link key={label} to={to} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur transition-colors hover:bg-white/10">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={label} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Getting started</CardTitle>
|
||||
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="divide-y">
|
||||
<Step done icon={<Check />} title="Create your sign-in" detail="" />
|
||||
<Step
|
||||
done={me.hasPasskey}
|
||||
icon={<ScanFace />}
|
||||
title="Turn on Face ID or fingerprint sign-in"
|
||||
detail="One glance instead of a password, if this device is ever signed out."
|
||||
action={
|
||||
<Link to="/settings/security" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Set up <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={installed}
|
||||
icon={isIos() ? <Share /> : <Smartphone />}
|
||||
title="Add Spendlight to your home screen"
|
||||
detail={
|
||||
isIos()
|
||||
? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.'
|
||||
: 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasAccounts}
|
||||
icon={<Landmark />}
|
||||
title="Add your accounts"
|
||||
detail="Checking, savings and every card, each with its own color. Or skip ahead: importing a statement offers to create its account."
|
||||
action={
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Add <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasMoney}
|
||||
icon={<Upload />}
|
||||
title="Drop in your statements"
|
||||
detail="Download a CSV from each bank’s website and drop them in together. Spendlight recognizes the bank, skips duplicates and fills in categories."
|
||||
action={
|
||||
<Link to="/import" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Import <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasMoney && toReview === 0}
|
||||
icon={<Inbox />}
|
||||
title="Review what came in"
|
||||
detail={toReview ? `${toReview} transaction${toReview === 1 ? '' : 's'} waiting, most already filled in. Usually a few taps.` : 'Confirm Spendlight’s guesses. It learns from every choice.'}
|
||||
action={
|
||||
toReview ? (
|
||||
<Link to="/review" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Review <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Step icon={<Target />} later="Coming in milestone 2" title="Set your monthly budgets" detail="Start from last month’s spending and adjust from there." />
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
web/src/pages/ImportPage.tsx
Normal file
432
web/src/pages/ImportPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { formatCents, type Account, type ImportColumnMap, type ImportPreview, type ImportSummary } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRight, Check, CircleAlert, FileText, Inbox, Loader2, Plus, Undo2, Upload, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { MappingDialog } from '@/components/MappingDialog';
|
||||
import { formatDay } from '@/components/money';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { Select } from '@/components/ui/form';
|
||||
import { Badge } from '@/components/ui/misc';
|
||||
import { api, ApiError, errorMessage } from '@/lib/api';
|
||||
import { MONEY_KEYS, useAccounts, useReviewCount } from '@/lib/data';
|
||||
import { onFilesQueued, takeFiles } from '@/lib/pendingFiles';
|
||||
import { cn, timeAgo } from '@/lib/utils';
|
||||
|
||||
type Status = 'reading' | 'needs-mapping' | 'needs-account' | 'ready' | 'importing' | 'done' | 'error';
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
file: File;
|
||||
status: Status;
|
||||
preview?: ImportPreview;
|
||||
accountId?: number;
|
||||
mapping?: ImportColumnMap;
|
||||
include: string[];
|
||||
summary?: ImportSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function uploadFile(file: File): Promise<ImportPreview> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/v1/imports/files', { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'error', data?.error?.message ?? 'Couldn’t read that file.');
|
||||
return data as ImportPreview;
|
||||
}
|
||||
|
||||
const statusFor = (p: ImportPreview, accountId?: number): Status =>
|
||||
!p.layout ? 'needs-mapping' : !(accountId ?? p.account?.id) ? 'needs-account' : 'ready';
|
||||
|
||||
function range(p: ImportPreview) {
|
||||
if (!p.dateFrom || !p.dateTo) return null;
|
||||
return p.dateFrom === p.dateTo ? formatDay(p.dateFrom, true) : `${formatDay(p.dateFrom, true)} – ${formatDay(p.dateTo, true)}`;
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
item,
|
||||
accounts,
|
||||
onAccount,
|
||||
onNewAccount,
|
||||
onMap,
|
||||
onToggle,
|
||||
onImport,
|
||||
onRemove,
|
||||
onUndo,
|
||||
}: {
|
||||
item: Item;
|
||||
accounts: Account[];
|
||||
onAccount: (id: number) => void;
|
||||
onNewAccount: () => void;
|
||||
onMap: () => void;
|
||||
onToggle: (key: string) => void;
|
||||
onImport: () => void;
|
||||
onRemove: () => void;
|
||||
onUndo: () => void;
|
||||
}) {
|
||||
const p = item.preview;
|
||||
const accountId = item.accountId ?? p?.account?.id;
|
||||
const open = accounts.filter((a) => a.status === 'open');
|
||||
const toImport = p ? p.counts.new + item.include.length : 0;
|
||||
|
||||
return (
|
||||
<Card className="animate-fade-up overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 sm:px-5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
|
||||
item.status === 'done' ? 'bg-good/12 text-good' : item.status === 'error' ? 'bg-bad/12 text-bad' : 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{item.status === 'done' ? <Check /> : item.status === 'error' ? <CircleAlert /> : item.status === 'reading' || item.status === 'importing' ? <Loader2 className="animate-spin" /> : <FileText />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-medium">{item.file.name}</p>
|
||||
<p className="truncate text-[12.5px] text-muted-foreground">
|
||||
{item.status === 'reading'
|
||||
? 'Reading…'
|
||||
: item.status === 'error'
|
||||
? item.error
|
||||
: [p?.layout?.name ?? (p ? 'A layout Spendlight hasn’t seen yet' : null), p ? range(p) : null].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
{item.status !== 'importing' && item.status !== 'done' ? (
|
||||
<Button variant="ghost" size="icon" aria-label={`Remove ${item.file.name}`} onClick={onRemove}>
|
||||
<X className="text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{p && item.status === 'needs-mapping' ? (
|
||||
<div className="border-t px-4 py-4 sm:px-5">
|
||||
<p className="text-[14px] text-muted-foreground">Show Spendlight which column is which, once. It will recognize this bank’s files after that.</p>
|
||||
<Button className="mt-3" onClick={onMap}>
|
||||
Set up columns <ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{p && p.layout && item.status !== 'done' && item.status !== 'reading' ? (
|
||||
<div className="grid gap-4 border-t px-4 py-4 sm:px-5">
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-[13px] font-medium" htmlFor={`acct-${item.key}`}>
|
||||
{accountId ? 'Account' : 'Which account is this?'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
id={`acct-${item.key}`}
|
||||
value={accountId ?? ''}
|
||||
onChange={(e) => (e.target.value === 'new' ? onNewAccount() : onAccount(Number(e.target.value)))}
|
||||
className={cn('min-w-0 flex-1 sm:max-w-xs', !accountId && 'border-warn/60')}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose an account
|
||||
</option>
|
||||
{open.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{a.last4 ? ` ••${a.last4}` : ''}
|
||||
</option>
|
||||
))}
|
||||
{p.accountHint ? <option value="new">+ New account: {p.accountHint.name}</option> : <option value="new">+ New account</option>}
|
||||
</Select>
|
||||
</div>
|
||||
{p.account && accountId === p.account.id ? <p className="text-[12.5px] text-muted-foreground">{p.account.reason}</p> : null}
|
||||
</div>
|
||||
|
||||
{accountId ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge tone="primary">{p.counts.new} new</Badge>
|
||||
{p.counts.duplicates ? <Badge>{p.counts.duplicates} already imported</Badge> : null}
|
||||
{p.counts.possibleDuplicates ? <Badge tone="warn">{p.counts.possibleDuplicates} possible duplicates</Badge> : null}
|
||||
{p.counts.skipped ? <Badge>{p.counts.skipped} lines skipped</Badge> : null}
|
||||
</div>
|
||||
{p.possibleDuplicates.length ? (
|
||||
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="text-[13px] font-medium">These look like transactions you already have, a few days apart. Skipped unless you tick them:</p>
|
||||
<ul className="mt-2 grid gap-1.5">
|
||||
{p.possibleDuplicates.map((d) => (
|
||||
<li key={d.key}>
|
||||
<label className="flex items-center gap-2.5 text-[13.5px]">
|
||||
<input type="checkbox" className="size-4 accent-[var(--primary)]" checked={item.include.includes(d.key)} onChange={() => onToggle(d.key)} />
|
||||
<span className="flex-1 truncate">
|
||||
{formatDay(d.date, true)} · {d.merchant}
|
||||
<span className="text-muted-foreground"> (you have one on {formatDay(d.existingDate, true)})</span>
|
||||
</span>
|
||||
<span className="tabular">{formatCents(d.amountCents)}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{p.newRows.length ? (
|
||||
<ul className="divide-y rounded-xl border text-[13.5px]">
|
||||
{p.newRows.slice(0, 5).map((r, i) => (
|
||||
<li key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">{formatDay(r.date, true)}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{r.merchant}</span>
|
||||
<span className={cn('tabular', r.amountCents > 0 && 'text-good')}>{formatCents(r.amountCents, { sign: r.amountCents > 0 })}</span>
|
||||
</li>
|
||||
))}
|
||||
{p.counts.new > 5 ? <li className="px-3 py-2 text-muted-foreground">and {p.counts.new - 5} more</li> : null}
|
||||
</ul>
|
||||
) : null}
|
||||
<Button onClick={onImport} loading={item.status === 'importing'} disabled={toImport === 0} className="justify-self-start">
|
||||
{toImport === 0 ? 'Nothing new to import' : `Import ${toImport} transaction${toImport === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.status === 'done' && item.summary ? (
|
||||
<div className="flex flex-wrap items-center gap-3 border-t px-4 py-3.5 sm:px-5">
|
||||
<p className="flex-1 text-[14px]">
|
||||
Added <strong>{item.summary.addedCount}</strong> to {item.summary.accountName}
|
||||
{item.summary.transferCount ? `, matched ${item.summary.transferCount} transfer${item.summary.transferCount === 1 ? '' : 's'}` : ''}.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={onUndo}>
|
||||
<Undo2 /> Undo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const client = useQueryClient();
|
||||
const accounts = useAccounts();
|
||||
const recent = useQuery({ queryKey: ['imports'], queryFn: () => api<ImportSummary[]>('/imports') });
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [mapping, setMapping] = useState<Item | null>(null);
|
||||
const [savingMap, setSavingMap] = useState(false);
|
||||
const [undoing, setUndoing] = useState<{ id: number; name: string } | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const patch = (key: string, change: Partial<Item>) => setItems((list) => list.map((i) => (i.key === key ? { ...i, ...change } : i)));
|
||||
const refreshMoney = () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
|
||||
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
const fresh = files.map((file) => ({ key: crypto.randomUUID(), file, status: 'reading' as Status, include: [] }));
|
||||
setItems((list) => [...fresh, ...list]);
|
||||
for (const item of fresh) {
|
||||
uploadFile(item.file)
|
||||
.then((preview) => patch(item.key, { preview, status: statusFor(preview), accountId: preview.account?.id }))
|
||||
.catch((error) => patch(item.key, { status: 'error', error: errorMessage(error) }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = takeFiles();
|
||||
if (pending.length) addFiles(pending);
|
||||
return onFilesQueued(() => addFiles(takeFiles()));
|
||||
}, [addFiles]);
|
||||
|
||||
const repreview = async (item: Item, change: { accountId?: number; mapping?: ImportColumnMap }) => {
|
||||
const accountId = change.accountId ?? item.accountId;
|
||||
const map = change.mapping ?? item.mapping;
|
||||
const preview = await api<ImportPreview>('/imports/preview', {
|
||||
method: 'POST',
|
||||
body: { stagingId: item.preview!.stagingId, ...(accountId ? { accountId } : {}), ...(map ? { mapping: map } : {}) },
|
||||
});
|
||||
patch(item.key, { preview, accountId, mapping: map, include: [], status: statusFor(preview, accountId) });
|
||||
};
|
||||
|
||||
const newAccount = async (item: Item) => {
|
||||
const hint = item.preview?.accountHint;
|
||||
try {
|
||||
const account = await api<Account>('/accounts', {
|
||||
method: 'POST',
|
||||
body: hint ? { name: hint.name, kind: hint.kind, institution: hint.institution, last4: hint.last4 } : { name: 'New account', kind: 'checking' },
|
||||
});
|
||||
toast.success(`Created ${account.name}`, { description: 'You can rename it any time in Accounts.' });
|
||||
void client.invalidateQueries({ queryKey: ['accounts'] });
|
||||
await repreview(item, { accountId: account.id });
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const doImport = async (item: Item) => {
|
||||
patch(item.key, { status: 'importing' });
|
||||
try {
|
||||
const summary = await api<ImportSummary>('/imports', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
stagingId: item.preview!.stagingId,
|
||||
accountId: item.accountId ?? item.preview!.account!.id,
|
||||
...(item.mapping ? { mapping: item.mapping } : {}),
|
||||
includePossibleDuplicates: item.include,
|
||||
},
|
||||
});
|
||||
patch(item.key, { status: 'done', summary });
|
||||
refreshMoney();
|
||||
} catch (error) {
|
||||
patch(item.key, { status: 'ready' });
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const undo = useMutation({
|
||||
mutationFn: (id: number) => api<ImportSummary>(`/imports/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: (s) => {
|
||||
toast.success(`Undone: removed ${s.addedCount} transaction${s.addedCount === 1 ? '' : 's'} from ${s.accountName}`);
|
||||
setItems((list) => list.filter((i) => i.summary?.id !== s.id));
|
||||
setUndoing(null);
|
||||
refreshMoney();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const ready = items.filter((i) => i.status === 'ready' && (i.preview?.counts.new ?? 0) + i.include.length > 0);
|
||||
const { data: reviewCount } = useReviewCount();
|
||||
const imported = items.some((i) => i.status === 'done');
|
||||
const toReview = imported ? (reviewCount ?? 0) : 0;
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Import statements"
|
||||
description="Add your bank and card downloads (CSV files). Spendlight recognizes the bank, skips anything already imported, and fills in categories it knows."
|
||||
actions={
|
||||
ready.length > 1 ? (
|
||||
<Button onClick={() => ready.forEach((i) => void doImport(i))}>Import all {ready.length} files</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
'relative mb-5 flex flex-col items-center justify-center rounded-3xl border-2 border-dashed px-6 py-10 text-center transition-colors',
|
||||
dragging ? 'border-primary bg-accent' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<span className="mb-3 flex size-12 items-center justify-center rounded-2xl bg-accent text-accent-foreground">
|
||||
<Upload className="size-6" strokeWidth={1.8} />
|
||||
</span>
|
||||
<p className="text-[16px] font-semibold">Drop statement files here</p>
|
||||
<p className="mt-1 text-[13.5px] text-muted-foreground">As many as you like, from any of your banks</p>
|
||||
<Button className="mt-4" onClick={() => inputRef.current?.click()}>
|
||||
<Plus /> Choose files
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,text/csv"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) addFiles([...e.target.files]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toReview > 0 ? (
|
||||
<Card className="mb-5 flex flex-wrap items-center gap-3 border-primary/30 bg-accent/60 p-4 sm:px-5">
|
||||
<Inbox className="size-5 text-primary" />
|
||||
<p className="flex-1 text-[14.5px] font-medium">
|
||||
{toReview} transaction{toReview === 1 ? '' : 's'} ready to review, most already filled in.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link to="/review">
|
||||
Review now <ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<FileCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
accounts={accounts.data ?? []}
|
||||
onAccount={(id) => void repreview(item, { accountId: id }).catch((e) => toast.error(errorMessage(e)))}
|
||||
onNewAccount={() => void newAccount(item)}
|
||||
onMap={() => setMapping(item)}
|
||||
onToggle={(key) => patch(item.key, { include: item.include.includes(key) ? item.include.filter((k) => k !== key) : [...item.include, key] })}
|
||||
onImport={() => void doImport(item)}
|
||||
onRemove={() => setItems((list) => list.filter((i) => i.key !== item.key))}
|
||||
onUndo={() => item.summary && setUndoing({ id: item.summary.id, name: item.file.name })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recent.data?.length ? (
|
||||
<section className="mt-8">
|
||||
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">Recent imports</h3>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{recent.data.slice(0, 12).map((s) => (
|
||||
<div key={s.id} className={cn('flex items-center gap-3 px-4 py-3 sm:px-5', s.status === 'undone' && 'opacity-55')}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14.5px] font-medium">{s.fileName}</p>
|
||||
<p className="truncate text-[12.5px] text-muted-foreground">
|
||||
{s.accountName} · {s.status === 'undone' ? 'undone' : `${s.addedCount} added`} · {timeAgo(s.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{s.status === 'committed' ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setUndoing({ id: s.id, name: s.fileName })}>
|
||||
<Undo2 /> Undo
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{mapping?.preview ? (
|
||||
<MappingDialog
|
||||
preview={mapping.preview}
|
||||
open
|
||||
saving={savingMap}
|
||||
onOpenChange={(o) => !o && setMapping(null)}
|
||||
onSave={async (map) => {
|
||||
setSavingMap(true);
|
||||
try {
|
||||
await repreview(mapping, { mapping: map });
|
||||
setMapping(null);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
} finally {
|
||||
setSavingMap(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!undoing}
|
||||
onOpenChange={(o) => !o && setUndoing(null)}
|
||||
title="Undo this import?"
|
||||
description={`Everything ${undoing?.name ?? 'this file'} added is removed, including any categories you’ve chosen for those transactions. You can import the file again afterwards.`}
|
||||
confirmLabel="Undo import"
|
||||
destructive
|
||||
loading={undo.isPending}
|
||||
onConfirm={() => undoing && undo.mutate(undoing.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
web/src/pages/MorePage.tsx
Normal file
38
web/src/pages/MorePage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight, LogOut } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { Wordmark, VersionTag } from '@/components/brand';
|
||||
import { NAV, SETTINGS_NAV } from '@/components/nav';
|
||||
import { useConfig, useSignOut } from '@/lib/session';
|
||||
|
||||
/** The phone's "More" tab: everything that doesn't fit on the bottom bar. */
|
||||
export default function MorePage() {
|
||||
const signOut = useSignOut();
|
||||
const { data: config } = useConfig();
|
||||
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<nav className="overflow-hidden rounded-2xl border bg-card shadow-soft" aria-label="More">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<Link key={to} to={to} className="flex h-14 items-center gap-3.5 border-b px-4 last:border-b-0 active:bg-muted">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-accent text-accent-foreground">
|
||||
<Icon className="size-[18px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="flex-1 text-[15px] font-medium">{label}</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
onClick={() => signOut.mutate()}
|
||||
className="flex h-14 items-center gap-3.5 rounded-2xl border bg-card px-4 text-[15px] font-medium text-bad shadow-soft active:bg-muted"
|
||||
>
|
||||
<LogOut className="size-[18px]" /> Sign out
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-1.5 pt-4 text-center">
|
||||
<Wordmark className="opacity-80" />
|
||||
<VersionTag version={config?.version} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/pages/NotFoundPage.tsx
Normal file
16
web/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Compass } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { EmptyState } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16">
|
||||
<EmptyState icon={Compass} title="Nothing here" description="That page doesn’t exist. It may have moved, or the link may be mistyped.">
|
||||
<Button asChild>
|
||||
<Link to="/">Go to Home</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
web/src/pages/ReviewPage.tsx
Normal file
383
web/src/pages/ReviewPage.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { formatCents, type Category, type ReviewItem, type Transaction } from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeftRight, Check, CheckCheck, ChevronRight, Inbox, PartyPopper, Search, SkipForward, Split as SplitIcon, Upload } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryPicker } from '@/components/CategoryPicker';
|
||||
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
|
||||
import { EmptyState } from '@/components/page';
|
||||
import { TransactionSheet, useRefreshMoney } from '@/components/TransactionSheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts, useCategories } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { useIsPhone } from '@/lib/useMediaQuery';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Filed {
|
||||
transaction: Transaction;
|
||||
similar: { merchant: string; count: number; categoryId: number } | null;
|
||||
}
|
||||
|
||||
/** One suggestion button: the best guess is big and bold, alternates are quieter. */
|
||||
function Choice({ category, primary, onClick, hint }: { category: Category; primary?: boolean; onClick: () => void; hint?: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2.5 rounded-2xl border text-left font-medium transition-all active:scale-[0.98]',
|
||||
primary
|
||||
? 'h-14 w-full border-primary/40 bg-accent px-3.5 text-[16px] text-accent-foreground shadow-soft hover:border-primary sm:h-11 sm:w-auto sm:px-3 sm:text-[14px]'
|
||||
: 'h-12 flex-1 bg-card px-3 text-[14.5px] hover:bg-muted sm:h-11 sm:flex-none sm:px-3 sm:text-[14px]',
|
||||
)}
|
||||
>
|
||||
<CategoryIcon icon={category.icon} color={category.color} size="sm" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
{primary ? <Check className="ml-auto size-5 shrink-0 text-primary sm:ml-1 sm:size-4" /> : null}
|
||||
{hint ? <kbd className="ml-auto hidden rounded-md border bg-muted px-1.5 font-mono text-[11px] text-muted-foreground lg:inline">{hint}</kbd> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const phone = useIsPhone();
|
||||
const refresh = useRefreshMoney();
|
||||
const { byId } = useCategories();
|
||||
const accounts = useAccounts();
|
||||
const review = useQuery({ queryKey: ['review'], queryFn: () => api<ReviewItem[]>('/review') });
|
||||
const [gone, setGone] = useState<Set<number>>(new Set());
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [pickerFor, setPickerFor] = useState<number | null>(null);
|
||||
const [sheet, setSheet] = useState<{ t: Transaction; split: boolean } | null>(null);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const items = useMemo(() => (review.data ?? []).filter((i) => !gone.has(i.id)), [review.data, gone]);
|
||||
const current = items[Math.min(cursor, items.length - 1)];
|
||||
const prefilled = items.filter((i) => i.categoryId !== null);
|
||||
const accountOf = (id: number) => accounts.data?.find((a) => a.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursor > items.length - 1) setCursor(Math.max(0, items.length - 1));
|
||||
}, [items.length, cursor]);
|
||||
|
||||
const hide = (ids: number[]) => setGone((s) => new Set([...s, ...ids]));
|
||||
const unhide = (ids: number[]) => setGone((s) => new Set([...s].filter((x) => !ids.includes(x))));
|
||||
|
||||
const offerSimilar = (similar: Filed['similar']) => {
|
||||
if (!similar) return;
|
||||
const c = byId.get(similar.categoryId);
|
||||
toast(`Also file ${similar.count} other ${similar.merchant} ${similar.count === 1 ? 'purchase' : 'purchases'} as ${c?.name}?`, {
|
||||
description: 'Spendlight will remember this for future imports too.',
|
||||
duration: 12_000,
|
||||
action: {
|
||||
label: 'Yes',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const res = await api<{ updated: number }>('/transactions/bulk', {
|
||||
method: 'POST',
|
||||
body: { merchant: similar.merchant, categoryId: similar.categoryId, remember: true },
|
||||
});
|
||||
hide(items.filter((i) => i.merchant === similar.merchant).map((i) => i.id));
|
||||
toast.success(`Filed ${res.updated} more, and remembered ${similar.merchant}`);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const file = useCallback(
|
||||
async (item: ReviewItem, categoryId: number) => {
|
||||
hide([item.id]);
|
||||
try {
|
||||
const res = await api<Filed>(`/transactions/${item.id}`, { method: 'PATCH', body: { categoryId } });
|
||||
const name = byId.get(categoryId)?.name ?? 'that';
|
||||
toast.success(`${item.merchant} filed as ${name}`, {
|
||||
duration: 4000,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
await api(`/transactions/${item.id}`, { method: 'PATCH', body: { categoryId: item.categoryId, needsReview: true } }).catch(() => {});
|
||||
unhide([item.id]);
|
||||
refresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
offerSimilar(res.similar);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
unhide([item.id]);
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[byId, items],
|
||||
);
|
||||
|
||||
const acceptAll = async () => {
|
||||
setAccepting(true);
|
||||
try {
|
||||
const ids = prefilled.map((i) => i.id);
|
||||
const res = await api<{ accepted: number }>('/transactions/accept', { method: 'POST', body: { ids } });
|
||||
hide(ids);
|
||||
toast.success(`Filed ${res.accepted} transaction${res.accepted === 1 ? '' : 's'}`);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard on larger screens: arrows or j/k move, Enter files the best guess,
|
||||
// 1-3 pick a suggestion, / opens the full list, s splits.
|
||||
useEffect(() => {
|
||||
if (phone || !canEdit) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (sheet || pickerFor !== null || /input|textarea|select/i.test(target.tagName) || target.isContentEditable) return;
|
||||
if (!current) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'j') setCursor((c) => Math.min(items.length - 1, c + 1));
|
||||
else if (e.key === 'ArrowUp' || e.key === 'k') setCursor((c) => Math.max(0, c - 1));
|
||||
else if (e.key === 'Enter' && current.categoryId) void file(current, current.categoryId);
|
||||
else if (['1', '2', '3'].includes(e.key) && current.suggestions[Number(e.key) - 1]) void file(current, current.suggestions[Number(e.key) - 1]!);
|
||||
else if (e.key === '/') setPickerFor(current.id);
|
||||
else if (e.key === 's') setSheet({ t: current, split: true });
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [phone, canEdit, current, items.length, sheet, pickerFor, file]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [cursor]);
|
||||
|
||||
if (review.isPending) return <Skeleton className="h-72 rounded-3xl" />;
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={review.data?.length ? PartyPopper : Inbox}
|
||||
title={review.data?.length ? 'All caught up' : 'Nothing to review'}
|
||||
description={
|
||||
review.data?.length
|
||||
? 'Every transaction is filed. New ones appear here after your next import.'
|
||||
: 'When you import statements, new transactions land here with Spendlight’s best guess already filled in.'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button asChild>
|
||||
<Link to="/import">
|
||||
<Upload /> Import statements
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/activity">See all activity</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const suggestionCats = (item: ReviewItem) => item.suggestions.map((id) => byId.get(id)).filter((c): c is Category => !!c);
|
||||
|
||||
const header = (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[15px] text-muted-foreground">
|
||||
<strong className="text-foreground">{items.length}</strong> to review
|
||||
{prefilled.length ? (
|
||||
<>
|
||||
{' '}
|
||||
· <strong className="text-foreground">{prefilled.length}</strong> already filled in
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{canEdit && prefilled.length ? (
|
||||
<Button onClick={acceptAll} loading={accepting} className="w-full sm:w-auto">
|
||||
{accepting ? null : <CheckCheck />} Accept all {prefilled.length} suggestions
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---- Phone: one card at a time ---------------------------------------------
|
||||
if (phone && current) {
|
||||
const cats = suggestionCats(current);
|
||||
const best = current.categoryId ? byId.get(current.categoryId) : undefined;
|
||||
const alternates = cats.filter((c) => c.id !== best?.id).slice(0, 2);
|
||||
const account = accountOf(current.accountId);
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<Card className="animate-fade-up overflow-hidden p-5" key={current.id}>
|
||||
<div className="flex items-center justify-between gap-2 text-[12.5px] font-medium text-muted-foreground">
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
{account?.name}
|
||||
</span>
|
||||
<span>{formatDay(current.date)}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<h2 className="min-w-0 break-words text-[22px] font-semibold leading-tight tracking-tight">{current.merchant}</h2>
|
||||
<Amount cents={current.amountCents} className="text-[22px] font-semibold" />
|
||||
</div>
|
||||
<p className="mt-1 break-words text-[12.5px] text-muted-foreground">{current.description}</p>
|
||||
|
||||
{current.unpairedTransfer ? (
|
||||
<p className="mt-4 flex gap-2 rounded-xl bg-muted px-3.5 py-3 text-[13.5px]">
|
||||
<ArrowLeftRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
This looks like a payment to an account Spendlight doesn’t track. Is it a transfer, or spending?
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="mt-5 grid gap-2.5">
|
||||
{best ? (
|
||||
<>
|
||||
<p className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{current.confident ? 'You always file this as' : 'Best guess'}
|
||||
</p>
|
||||
<Choice category={best} primary onClick={() => void file(current, best.id)} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground">Choose a category</p>
|
||||
)}
|
||||
{alternates.length ? (
|
||||
<div className="flex gap-2">
|
||||
{alternates.map((c) => (
|
||||
<Choice key={c.id} category={c} onClick={() => void file(current, c.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<CategoryPicker
|
||||
value={current.categoryId}
|
||||
suggestions={current.suggestions}
|
||||
open={pickerFor === current.id}
|
||||
onOpenChange={(o) => setPickerFor(o ? current.id : null)}
|
||||
onPick={(c) => void file(current, c.id)}
|
||||
>
|
||||
<Button variant="outline" size="lg" className="w-full">
|
||||
<Search /> Something else…
|
||||
</Button>
|
||||
</CategoryPicker>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" onClick={() => setSheet({ t: current, split: true })}>
|
||||
<SplitIcon /> Split
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setCursor((c) => (c + 1) % items.length)}>
|
||||
<SkipForward /> Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{items.length > 1 ? (
|
||||
<section className="mt-6">
|
||||
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">Up next</h3>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{items
|
||||
.filter((i) => i.id !== current.id)
|
||||
.slice(0, 6)
|
||||
.map((i) => {
|
||||
const c = i.categoryId ? byId.get(i.categoryId) : undefined;
|
||||
return (
|
||||
<button key={i.id} onClick={() => setCursor(items.indexOf(i))} className="flex w-full items-center gap-3 px-4 py-3 text-left active:bg-muted">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('truncate text-[15px] font-medium', i.transferPeer && 'italic')}>{i.merchant}</p>
|
||||
<p className="truncate text-[12.5px] text-muted-foreground">{c ? c.name : 'Needs a category'}</p>
|
||||
</div>
|
||||
<Amount cents={i.amountCents} />
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
<TransactionSheet transaction={sheet?.t ?? null} startWithSplit={sheet?.split} onClose={() => setSheet(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Larger screens: a fast list --------------------------------------------
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
{canEdit ? (
|
||||
<p className="mb-3 hidden text-[12.5px] text-muted-foreground lg:block">
|
||||
Keyboard: <kbd className="rounded border bg-muted px-1 font-mono">↑</kbd> <kbd className="rounded border bg-muted px-1 font-mono">↓</kbd> move ·{' '}
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">Enter</kbd> accept the guess · <kbd className="rounded border bg-muted px-1 font-mono">1</kbd>–
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">3</kbd> pick a suggestion · <kbd className="rounded border bg-muted px-1 font-mono">/</kbd> all categories ·{' '}
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">S</kbd> split
|
||||
</p>
|
||||
) : null}
|
||||
<Card ref={listRef as never} className="divide-y overflow-hidden">
|
||||
{items.map((item, index) => {
|
||||
const account = accountOf(item.accountId);
|
||||
const cats = suggestionCats(item);
|
||||
const selected = index === cursor;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-row={index}
|
||||
onClick={() => setCursor(index)}
|
||||
className={cn('grid grid-cols-[1fr_auto] gap-x-4 gap-y-2.5 px-4 py-3.5 transition-colors sm:px-5 lg:grid-cols-[1fr_auto_auto] lg:items-center', selected && 'bg-accent/50')}
|
||||
>
|
||||
<button className="min-w-0 text-left" onClick={() => setSheet({ t: item, split: false })}>
|
||||
<p className={cn('truncate text-[15px] font-medium', item.transferPeer && 'italic')}>{item.merchant}</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
{account?.name} · {formatDay(item.date, true)}
|
||||
{item.unpairedTransfer ? ' · payment to an account you don’t track?' : ''}
|
||||
</p>
|
||||
</button>
|
||||
<Amount cents={item.amountCents} className="text-right text-[15px] lg:order-3 lg:w-28" />
|
||||
{canEdit ? (
|
||||
<div className="col-span-2 flex flex-wrap gap-2 lg:order-2 lg:col-span-1 lg:justify-end">
|
||||
{cats.map((c, n) => (
|
||||
<Choice
|
||||
key={c.id}
|
||||
category={c}
|
||||
primary={c.id === item.categoryId}
|
||||
hint={selected ? String(n + 1) : undefined}
|
||||
onClick={() => void file(item, c.id)}
|
||||
/>
|
||||
))}
|
||||
<CategoryPicker
|
||||
value={item.categoryId}
|
||||
suggestions={item.suggestions}
|
||||
open={pickerFor === item.id}
|
||||
onOpenChange={(o) => setPickerFor(o ? item.id : null)}
|
||||
onPick={(c) => void file(item, c.id)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-11" aria-label={`More categories for ${item.merchant}`}>
|
||||
<Search /> More
|
||||
</Button>
|
||||
</CategoryPicker>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
{items.length && prefilled.length === 0 ? null : (
|
||||
<p className="mt-3 text-center text-[12.5px] text-muted-foreground">
|
||||
Showing {items.length} · total {formatCents(items.reduce((s, i) => s + i.amountCents, 0))}
|
||||
</p>
|
||||
)}
|
||||
<TransactionSheet transaction={sheet?.t ?? null} startWithSplit={sheet?.split} onClose={() => setSheet(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/pages/SetupPage.tsx
Normal file
74
web/src/pages/SetupPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Me } from '@spendlight/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { AuthLayout, FormError } from '@/components/AuthLayout';
|
||||
import { Splash } from '@/components/RequireAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, Input, PasswordInput } from '@/components/ui/form';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useConfig } from '@/lib/session';
|
||||
|
||||
export default function SetupPage() {
|
||||
const config = useConfig();
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (config.isPending) return <Splash />;
|
||||
if (config.data && !config.data.needsSetup) return <Navigate to="/" replace />;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const me = await api<Me>('/setup', { method: 'POST', body: { displayName, username, password } });
|
||||
client.setQueryData(['me'], me);
|
||||
await client.invalidateQueries({ queryKey: ['config'] });
|
||||
toast.success(`Welcome to Spendlight, ${me.displayName}!`);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome to Spendlight" subtitle="Let’s create your sign-in. You’ll be the admin, so you can invite the rest of the household later.">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<FormError message={error} />
|
||||
<Field label="Your name">
|
||||
{(id) => <Input id={id} value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" placeholder="Chris" required />}
|
||||
</Field>
|
||||
<Field label="Username" hint="What you’ll type to sign in. Letters and numbers work best.">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="chris"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Password" hint="At least 8 characters. A short phrase is easy to remember.">
|
||||
{(id) => <PasswordInput id={id} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" minLength={8} required />}
|
||||
</Field>
|
||||
<Button type="submit" size="lg" loading={busy} className="mt-1 w-full">
|
||||
Create and continue
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
104
web/src/pages/SignInPage.tsx
Normal file
104
web/src/pages/SignInPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Me } from '@spendlight/shared';
|
||||
import { browserSupportsWebAuthn, startAuthentication, type PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ScanFace } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useNavigate, useSearchParams } from 'react-router';
|
||||
import { AuthLayout, FormError } from '@/components/AuthLayout';
|
||||
import { Splash } from '@/components/RequireAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, Input, PasswordInput } from '@/components/ui/form';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useConfig, useMe } from '@/lib/session';
|
||||
|
||||
export default function SignInPage() {
|
||||
const config = useConfig();
|
||||
const me = useMe();
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'password' | 'passkey' | null>(null);
|
||||
|
||||
if (config.isPending || me.isPending) return <Splash />;
|
||||
if (config.data?.needsSetup) return <Navigate to="/setup" replace />;
|
||||
if (me.data) return <Navigate to="/" replace />;
|
||||
|
||||
const next = params.get('next')?.startsWith('/') ? params.get('next')! : '/';
|
||||
const finish = (signedIn: Me) => {
|
||||
client.setQueryData(['me'], signedIn);
|
||||
navigate(next, { replace: true });
|
||||
};
|
||||
|
||||
const withPassword = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy('password');
|
||||
setError(null);
|
||||
try {
|
||||
finish(await api<Me>('/auth/login', { method: 'POST', body: { username, password } }));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const withPasskey = async () => {
|
||||
setBusy('passkey');
|
||||
setError(null);
|
||||
try {
|
||||
const optionsJSON = await api<PublicKeyCredentialRequestOptionsJSON>('/auth/passkey/options', { method: 'POST' });
|
||||
const response = await startAuthentication({ optionsJSON });
|
||||
finish(await api<Me>('/auth/passkey/verify', { method: 'POST', body: { response } }));
|
||||
} catch (err) {
|
||||
const cancelled = err instanceof Error && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
||||
setError(cancelled ? null : errorMessage(err));
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome back" subtitle="Your household’s money, in the spotlight.">
|
||||
<div className="grid gap-4">
|
||||
<FormError message={error} />
|
||||
{browserSupportsWebAuthn() ? (
|
||||
<>
|
||||
<Button size="lg" variant="outline" className="h-auto min-h-12 w-full whitespace-normal py-3" onClick={withPasskey} loading={busy === 'passkey'} disabled={busy !== null}>
|
||||
{busy === 'passkey' ? null : <ScanFace />}
|
||||
Use Face ID or fingerprint
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 text-[12px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
or
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<form onSubmit={withPassword} className="grid gap-4">
|
||||
<Field label="Username">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
{(id) => <PasswordInput id={id} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />}
|
||||
</Field>
|
||||
<Button type="submit" size="lg" loading={busy === 'password'} disabled={busy !== null} className="mt-1 w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center text-[12.5px] leading-relaxed text-muted-foreground">You’ll stay signed in on this device.</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
40
web/src/pages/Upcoming.tsx
Normal file
40
web/src/pages/Upcoming.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChartColumnBig, Repeat, Target, type LucideIcon } from 'lucide-react';
|
||||
import { EmptyState } from '@/components/page';
|
||||
|
||||
// Screens that arrive in later milestones, each explaining what it will do.
|
||||
|
||||
interface Upcoming {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
milestone: string;
|
||||
}
|
||||
|
||||
const SCREENS: Record<string, Upcoming> = {
|
||||
budget: {
|
||||
icon: Target,
|
||||
title: 'Monthly budgets',
|
||||
description:
|
||||
'Set a target for each category, copy last month in one tap, and see over or under for the month and the year at a glance.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
subscriptions: {
|
||||
icon: Repeat,
|
||||
title: 'Every subscription in one place',
|
||||
description:
|
||||
'Spendlight spots recurring charges across all your cards, totals them by month and year, and speaks up when a price changes or a cancelled service charges again.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
insights: {
|
||||
icon: ChartColumnBig,
|
||||
title: 'Where the money goes',
|
||||
description:
|
||||
'Month-by-month spending by category, budget against actual, year pacing, and money in against money out, each in plain words first.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
};
|
||||
|
||||
export default function UpcomingPage({ screen }: { screen: keyof typeof SCREENS }) {
|
||||
const s = SCREENS[screen]!;
|
||||
return <EmptyState icon={s.icon} title={s.title} description={s.description} milestone={s.milestone} />;
|
||||
}
|
||||
60
web/src/pages/settings/AboutPage.tsx
Normal file
60
web/src/pages/settings/AboutPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { versionDate } from '@spendlight/shared';
|
||||
import { BookOpen, HardDrive, ShieldCheck } from 'lucide-react';
|
||||
import { LogoMark } from '@/components/brand';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useConfig } from '@/lib/session';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
|
||||
export default function AboutPage() {
|
||||
const { data: config } = useConfig();
|
||||
const version = config?.version ?? APP_VERSION;
|
||||
const built = versionDate(version);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card className="relative overflow-hidden">
|
||||
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-40" aria-hidden />
|
||||
<CardContent className="relative flex flex-col items-center py-10 text-center sm:py-12">
|
||||
<LogoMark className="size-16 rounded-[22px] shadow-lift" />
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-tight">Spendlight</h2>
|
||||
<p className="mt-1 text-[14px] text-muted-foreground">Your household’s money, in the spotlight.</p>
|
||||
<dl className="mt-6 grid gap-1">
|
||||
<dt className="text-[12px] font-medium uppercase tracking-wider text-muted-foreground">Version</dt>
|
||||
<dd className="tabular text-[22px] font-semibold tracking-tight">{version}</dd>
|
||||
{built ? (
|
||||
<dd className="text-[13px] text-muted-foreground">
|
||||
Built {built.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}, build{' '}
|
||||
{Number(version.split('.')[1])} of the day
|
||||
</dd>
|
||||
) : (
|
||||
<dd className="text-[13px] text-muted-foreground">Development build</dd>
|
||||
)}
|
||||
{version !== APP_VERSION ? <dd className="text-[12.5px] text-warn">These screens are {APP_VERSION}; reload to update.</dd> : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 pt-5 text-[14px] sm:pt-6">
|
||||
<p className="flex gap-3">
|
||||
<ShieldCheck className="mt-0.5 size-[18px] shrink-0 text-good" />
|
||||
<span>Your data stays on your own server. Spendlight makes no calls to outside services.</span>
|
||||
</p>
|
||||
<p className="flex gap-3">
|
||||
<HardDrive className="mt-0.5 size-[18px] shrink-0 text-primary" />
|
||||
<span>Everything lives in one database file, copied to the backup folder every night.</span>
|
||||
</p>
|
||||
<p className="flex gap-3">
|
||||
<BookOpen className="mt-0.5 size-[18px] shrink-0 text-primary" />
|
||||
<span>
|
||||
Building on Spendlight? The{' '}
|
||||
<a href="/api/docs" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
API documentation
|
||||
</a>{' '}
|
||||
describes everything the screens can do.
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
web/src/pages/settings/BackupsPage.tsx
Normal file
88
web/src/pages/settings/BackupsPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { BackupInfo, BackupKind, BackupOverview } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, Download, FolderSync } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||
|
||||
const KIND: Record<BackupKind, { label: string; tone: 'neutral' | 'primary' | 'warn' }> = {
|
||||
nightly: { label: 'Nightly', tone: 'neutral' },
|
||||
manual: { label: 'Manual', tone: 'primary' },
|
||||
'pre-upgrade': { label: 'Before upgrade', tone: 'warn' },
|
||||
'pre-restore': { label: 'Before restore', tone: 'warn' },
|
||||
};
|
||||
|
||||
function time12(hhmm: string): string {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return new Date(2000, 0, 1, h, m).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const client = useQueryClient();
|
||||
const overview = useQuery({ queryKey: ['backups'], queryFn: () => api<BackupOverview>('/backups') });
|
||||
const backUp = useMutation({
|
||||
mutationFn: () => api<BackupInfo>('/backups', { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Backed up');
|
||||
void client.invalidateQueries({ queryKey: ['backups'] });
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<PageIntro
|
||||
title="Backups"
|
||||
description="Spendlight copies everything to the backup folder every night. Point your backup tool at that folder to keep a copy off this server."
|
||||
actions={
|
||||
<Button onClick={() => backUp.mutate()} loading={backUp.isPending}>
|
||||
{backUp.isPending ? null : <Archive />} Back up now
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{overview.data ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderSync className="size-[18px] text-primary" /> Every night at {time12(overview.data.nightlyAt)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are
|
||||
also taken before every upgrade.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0 sm:p-0">
|
||||
{overview.isPending ? <Skeleton className="m-4 h-12" /> : null}
|
||||
{overview.data?.backups.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-[14px] text-muted-foreground">No backups yet. The first one runs tonight, or tap Back up now.</p>
|
||||
) : null}
|
||||
<ul className="divide-y">
|
||||
{overview.data?.backups.map((b) => (
|
||||
<li key={b.name} className="flex items-center gap-3 px-4 py-3 sm:px-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[14.5px] font-medium">{formatDateTime(b.createdAt)}</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">{formatBytes(b.size)}</p>
|
||||
</div>
|
||||
<Badge tone={KIND[b.kind].tone}>{KIND[b.kind].label}</Badge>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={`/api/v1/backups/${encodeURIComponent(b.name)}/download`} download aria-label={`Download backup from ${formatDateTime(b.createdAt)}`}>
|
||||
<Download />
|
||||
</a>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
web/src/pages/settings/IdeasPage.tsx
Normal file
144
web/src/pages/settings/IdeasPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { IDEA_STATUSES, type Idea, type IdeaStatus } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ClipboardCopy, Download, Lightbulb } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Select, Textarea } from '@/components/ui/form';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { cn, formatDate } from '@/lib/utils';
|
||||
|
||||
/** "/settings/people" reads better as "Settings › people". */
|
||||
function screenName(path: string | null): string {
|
||||
if (!path) return 'an unknown screen';
|
||||
if (path === '/') return 'Home';
|
||||
const [first, ...rest] = path.split('/').filter(Boolean);
|
||||
const title = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
return [title(first ?? ''), ...rest].join(' › ');
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<IdeaStatus, string> = { new: 'New', planned: 'Planned', done: 'Done', declined: 'Declined' };
|
||||
const STATUS_TONE: Record<IdeaStatus, 'primary' | 'warn' | 'good' | 'neutral'> = {
|
||||
new: 'primary',
|
||||
planned: 'warn',
|
||||
done: 'good',
|
||||
declined: 'neutral',
|
||||
};
|
||||
|
||||
function IdeaCard({ idea }: { idea: Idea }) {
|
||||
const client = useQueryClient();
|
||||
const [note, setNote] = useState(idea.adminNote ?? '');
|
||||
const save = useMutation({
|
||||
mutationFn: (body: { status?: IdeaStatus; adminNote?: string | null }) =>
|
||||
api<Idea>(`/ideas/${idea.id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => void client.invalidateQueries({ queryKey: ['ideas'] }),
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium text-muted-foreground">#{idea.id}</span>
|
||||
<Badge tone={STATUS_TONE[idea.status]}>{STATUS_LABEL[idea.status]}</Badge>
|
||||
</div>
|
||||
<h3 className="mt-1.5 text-[16px] font-semibold leading-snug">{idea.title}</h3>
|
||||
{idea.description ? <p className="mt-1.5 whitespace-pre-wrap text-[14px] leading-relaxed text-muted-foreground">{idea.description}</p> : null}
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">
|
||||
{idea.author ?? 'Someone'} · {formatDate(idea.createdAt)} · on {screenName(idea.path)} · v{idea.appVersion ?? '?'}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
aria-label="Status"
|
||||
value={idea.status}
|
||||
onChange={(e) => save.mutate({ status: e.target.value as IdeaStatus })}
|
||||
className="w-full sm:w-36"
|
||||
>
|
||||
{IDEA_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="Note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={() => note !== (idea.adminNote ?? '') && save.mutate({ adminNote: note || null })}
|
||||
placeholder="Add a note (saved when you click away)"
|
||||
className="mt-4 min-h-16"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdeasPage() {
|
||||
const [filter, setFilter] = useState<IdeaStatus | 'all'>('new');
|
||||
const ideas = useQuery({ queryKey: ['ideas'], queryFn: () => api<Idea[]>('/ideas') });
|
||||
const shown = ideas.data?.filter((i) => filter === 'all' || i.status === filter) ?? [];
|
||||
|
||||
const exportUrl = `/api/v1/ideas/export.md${filter === 'all' ? '' : `?status=${filter}`}`;
|
||||
const copyMarkdown = async () => {
|
||||
try {
|
||||
const res = await fetch(exportUrl, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error('Couldn’t export ideas.');
|
||||
await navigator.clipboard.writeText(await res.text());
|
||||
toast.success('Copied as Markdown, ready to paste into Claude.');
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const count = (s: IdeaStatus | 'all') => (s === 'all' ? ideas.data?.length : ideas.data?.filter((i) => i.status === s).length) ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Ideas"
|
||||
description="Everything shared through the lightbulb. Copy them as Markdown to review and build with Claude."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={copyMarkdown}>
|
||||
<ClipboardCopy /> Copy
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href={exportUrl} download>
|
||||
<Download /> .md
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="-mx-4 mb-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
|
||||
{(['new', 'planned', 'done', 'declined', 'all'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 items-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
|
||||
filter === s ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'all' ? 'All' : STATUS_LABEL[s]}
|
||||
<span className="tabular opacity-70">{count(s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{ideas.isPending ? <Skeleton className="h-32 rounded-2xl" /> : null}
|
||||
{ideas.data && shown.length === 0 ? (
|
||||
<EmptyState icon={Lightbulb} title="Nothing here yet" description="Ideas shared with the lightbulb at the top right of every screen show up here." />
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{shown.map((idea) => (
|
||||
<IdeaCard key={`${idea.id}-${idea.updatedAt}`} idea={idea} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
web/src/pages/settings/PeoplePage.tsx
Normal file
257
web/src/pages/settings/PeoplePage.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { ACCESS_LABEL, type Access, type Person } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Copy, Ellipsis, KeyRound, ShieldCheck, ShieldOff, Trash2, UserPlus, Eye, Pencil } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar } from '@/components/UserMenu';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/menu';
|
||||
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
interface WithPassword {
|
||||
person: Person;
|
||||
temporaryPassword: string;
|
||||
}
|
||||
|
||||
function TemporaryPassword({ result, onClose }: { result: WithPassword | null; onClose: () => void }) {
|
||||
return (
|
||||
<Dialog open={!!result} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
title={`Share this with ${result?.person.displayName ?? ''}`}
|
||||
description="It works once. They’ll choose their own password the first time they sign in."
|
||||
>
|
||||
{result ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1 rounded-2xl bg-muted p-4 text-center">
|
||||
<p className="text-[12.5px] text-muted-foreground">Username</p>
|
||||
<p className="text-[17px] font-semibold">{result.person.username}</p>
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Temporary password</p>
|
||||
<p className="tabular font-mono text-[22px] font-semibold tracking-wider">{result.temporaryPassword}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(result.temporaryPassword);
|
||||
toast.success('Copied');
|
||||
}}
|
||||
>
|
||||
<Copy /> Copy password
|
||||
</Button>
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPerson({ open, onOpenChange, onAdded }: { open: boolean; onOpenChange: (o: boolean) => void; onAdded: (r: WithPassword) => void }) {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [access, setAccess] = useState<Access>('full');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const add = useMutation({
|
||||
mutationFn: () => api<WithPassword>('/people', { method: 'POST', body: { displayName, username, access, isAdmin } }),
|
||||
onSuccess: (result) => {
|
||||
setDisplayName('');
|
||||
setUsername('');
|
||||
setAccess('full');
|
||||
setIsAdmin(false);
|
||||
onOpenChange(false);
|
||||
onAdded(result);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
add.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title="Add a person" description="They’ll get a temporary password to share with them.">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Name">{(id) => <Input id={id} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required autoFocus />}</Field>
|
||||
<Field label="Username">
|
||||
{(id) => (
|
||||
<Input id={id} value={username} onChange={(e) => setUsername(e.target.value)} autoCapitalize="none" autoCorrect="off" spellCheck={false} required />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Access" hint="Read-only people can see everything but change nothing.">
|
||||
{(id) => (
|
||||
<Select id={id} value={access} onChange={(e) => setAccess(e.target.value as Access)}>
|
||||
<option value="full">Full access</option>
|
||||
<option value="read">Read-only</option>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center justify-between gap-4 rounded-xl border px-3.5 py-3">
|
||||
<span>
|
||||
<span className="block text-[14px] font-medium">Admin</span>
|
||||
<span className="block text-[12.5px] text-muted-foreground">Can manage people, backups and settings</span>
|
||||
</span>
|
||||
<Switch checked={isAdmin} onCheckedChange={setIsAdmin} />
|
||||
</label>
|
||||
<Button type="submit" loading={add.isPending} className="mt-1">
|
||||
Add person
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RenamePerson({ person, onClose }: { person: Person | null; onClose: () => void }) {
|
||||
const client = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const save = useMutation({
|
||||
mutationFn: () => api<Person>(`/people/${person!.id}`, { method: 'PATCH', body: { displayName: name } }),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['people'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
return (
|
||||
<Dialog open={!!person} onOpenChange={(open) => (open ? setName(person?.displayName ?? '') : onClose())}>
|
||||
<DialogContent title="Rename" onOpenAutoFocus={() => setName(person?.displayName ?? '')}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required />}</Field>
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PeoplePage() {
|
||||
const me = useCurrentUser();
|
||||
const client = useQueryClient();
|
||||
const people = useQuery({ queryKey: ['people'], queryFn: () => api<Person[]>('/people') });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [shared, setShared] = useState<WithPassword | null>(null);
|
||||
const [removing, setRemoving] = useState<Person | null>(null);
|
||||
const [renaming, setRenaming] = useState<Person | null>(null);
|
||||
const refresh = () => void client.invalidateQueries({ queryKey: ['people'] });
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: Partial<Pick<Person, 'access' | 'isAdmin'>> }) =>
|
||||
api<Person>(`/people/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: (p) => {
|
||||
toast.success(`Updated ${p.displayName}`);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: (id: number) => api<WithPassword>(`/people/${id}/reset-password`, { method: 'POST' }),
|
||||
onSuccess: (r) => {
|
||||
setShared(r);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api(`/people/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Removed');
|
||||
setRemoving(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="People"
|
||||
description="Everyone in the household signs in with their own login and shares the same books."
|
||||
actions={
|
||||
<Button onClick={() => setAdding(true)}>
|
||||
<UserPlus /> Add person
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card className="divide-y">
|
||||
{people.isPending ? <Skeleton className="m-4 h-12" /> : null}
|
||||
{people.data?.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-3.5 px-4 py-3.5 sm:px-5">
|
||||
<Avatar name={p.displayName} className="size-10 text-[13px]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-[15px] font-medium">
|
||||
{p.displayName}
|
||||
{p.id === me.id ? <span className="text-[12.5px] font-normal text-muted-foreground">(you)</span> : null}
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
@{p.username} · {p.mustChangePassword ? 'hasn’t signed in yet' : `active ${timeAgo(p.lastActiveAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden gap-1.5 sm:flex">
|
||||
<Badge tone={p.access === 'full' ? 'primary' : 'neutral'}>{ACCESS_LABEL[p.access]}</Badge>
|
||||
{p.isAdmin ? <Badge tone="warn">Admin</Badge> : null}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={`Options for ${p.displayName}`}>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setRenaming(p)}>
|
||||
<Pencil /> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => update.mutate({ id: p.id, body: { access: p.access === 'full' ? 'read' : 'full' } })}>
|
||||
<Eye /> {p.access === 'full' ? 'Make read-only' : 'Give full access'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => update.mutate({ id: p.id, body: { isAdmin: !p.isAdmin } })}>
|
||||
{p.isAdmin ? <ShieldOff /> : <ShieldCheck />} {p.isAdmin ? 'Remove admin' : 'Make admin'}
|
||||
</DropdownMenuItem>
|
||||
{p.id !== me.id ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => reset.mutate(p.id)}>
|
||||
<KeyRound /> Reset password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onSelect={() => setRemoving(p)}>
|
||||
<Trash2 /> Remove
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<AddPerson open={adding} onOpenChange={setAdding} onAdded={(r) => { setShared(r); refresh(); }} />
|
||||
<TemporaryPassword result={shared} onClose={() => setShared(null)} />
|
||||
<RenamePerson person={renaming} onClose={() => setRenaming(null)} />
|
||||
<ConfirmDialog
|
||||
open={!!removing}
|
||||
onOpenChange={(open) => !open && setRemoving(null)}
|
||||
title={`Remove ${removing?.displayName ?? ''}?`}
|
||||
description="They’ll be signed out everywhere and can’t sign in again. Anything they changed keeps their name in the history."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => removing && remove.mutate(removing.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
web/src/pages/settings/ProfilePage.tsx
Normal file
88
web/src/pages/settings/ProfilePage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ACCESS_LABEL, type Me } from '@spendlight/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar } from '@/components/UserMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field, Input } from '@/components/ui/form';
|
||||
import { Badge, Segmented } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { useSetTheme } from '@/lib/useSetTheme';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const me = useCurrentUser();
|
||||
const client = useQueryClient();
|
||||
const setTheme = useSetTheme();
|
||||
const [name, setName] = useState(me.displayName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const saveName = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
client.setQueryData(['me'], await api<Me>('/me', { method: 'PATCH', body: { displayName: name } }));
|
||||
toast.success('Name saved');
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-5 sm:pt-6">
|
||||
<Avatar name={me.displayName} className="size-14 text-lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[17px] font-semibold">{me.displayName}</p>
|
||||
<p className="text-[13.5px] text-muted-foreground">@{me.username}</p>
|
||||
<div className="mt-1.5 flex gap-1.5">
|
||||
<Badge tone={me.access === 'full' ? 'primary' : 'neutral'}>{ACCESS_LABEL[me.access]}</Badge>
|
||||
{me.isAdmin ? <Badge tone="warn">Admin</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your name</CardTitle>
|
||||
<CardDescription>How Spendlight greets you, and how you appear to the rest of the household.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={saveName} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required />}</Field>
|
||||
</div>
|
||||
<Button type="submit" loading={saving} disabled={name.trim() === me.displayName || !name.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>“Match my device” follows your phone or computer’s light and dark setting.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Segmented
|
||||
label="Theme"
|
||||
value={me.theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: 'system', label: 'Match device', icon: <Monitor /> },
|
||||
{ value: 'light', label: 'Light', icon: <Sun /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <Moon /> },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
web/src/pages/settings/SecurityPage.tsx
Normal file
217
web/src/pages/settings/SecurityPage.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { Device, Me, PasskeyInfo } from '@spendlight/shared';
|
||||
import { browserSupportsWebAuthn, startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { KeyRound, Laptop, ScanFace, Smartphone, Trash2 } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { Field, PasswordInput } from '@/components/ui/form';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
function FaceIdCard() {
|
||||
const client = useQueryClient();
|
||||
const keys = useQuery({ queryKey: ['passkeys'], queryFn: () => api<PasskeyInfo[]>('/passkeys') });
|
||||
const [removing, setRemoving] = useState<PasskeyInfo | null>(null);
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
const optionsJSON = await api<PublicKeyCredentialCreationOptionsJSON>('/passkeys/register/options', { method: 'POST' });
|
||||
const response = await startRegistration({ optionsJSON });
|
||||
return api<PasskeyInfo>('/passkeys/register/verify', { method: 'POST', body: { response } });
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
toast.success(`Face ID and fingerprint sign-in is on for ${key.name}`);
|
||||
void client.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
client.setQueryData<Me | null>(['me'], (me) => (me ? { ...me, hasPasskey: true } : me));
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) return;
|
||||
if (error instanceof Error && error.name === 'InvalidStateError') {
|
||||
toast.info('This device is already set up for Face ID or fingerprint sign-in.');
|
||||
return;
|
||||
}
|
||||
toast.error(errorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/passkeys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Removed');
|
||||
setRemoving(null);
|
||||
void client.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
void client.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const supported = browserSupportsWebAuthn();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ScanFace className="size-[18px] text-primary" /> Face ID and fingerprint
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in with a glance or a touch instead of your password. Set it up once on each phone or computer you use.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{keys.isPending ? <Skeleton className="h-14" /> : null}
|
||||
{keys.data?.map((key) => (
|
||||
<div key={key.id} className="flex items-center gap-3 rounded-xl border px-3.5 py-3">
|
||||
<KeyRound className="size-[18px] text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14.5px] font-medium">{key.name}</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Added {timeAgo(key.createdAt)} · last used {timeAgo(key.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" aria-label={`Remove ${key.name}`} onClick={() => setRemoving(key)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{supported ? (
|
||||
<Button onClick={() => add.mutate()} loading={add.isPending} className="justify-self-start">
|
||||
{add.isPending ? null : <ScanFace />} Set up on this device
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-[13.5px] text-muted-foreground">This browser doesn’t support Face ID or fingerprint sign-in.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<ConfirmDialog
|
||||
open={!!removing}
|
||||
onOpenChange={(open) => !open && setRemoving(null)}
|
||||
title={`Remove ${removing?.name ?? 'this passkey'}?`}
|
||||
description="That device will need your password next time it signs in. You can set it up again any time."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => removing && remove.mutate(removing.id)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordCard() {
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api<Me>('/me/password', { method: 'POST', body: { currentPassword: current, newPassword: next } });
|
||||
toast.success('Password changed. Your other devices were signed out.');
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Changing it signs out every other device, as a precaution.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className="grid gap-4 sm:max-w-sm">
|
||||
<Field label="Current password">
|
||||
{(id) => <PasswordInput id={id} value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />}
|
||||
</Field>
|
||||
<Field label="New password" hint="At least 8 characters.">
|
||||
{(id) => <PasswordInput id={id} value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={8} required />}
|
||||
</Field>
|
||||
<Button type="submit" loading={busy} className="justify-self-start">
|
||||
Change password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesCard() {
|
||||
const client = useQueryClient();
|
||||
const devices = useQuery({ queryKey: ['devices'], queryFn: () => api<Device[]>('/devices') });
|
||||
const refresh = () => void client.invalidateQueries({ queryKey: ['devices'] });
|
||||
|
||||
const signOutOne = useMutation({
|
||||
mutationFn: (id: string) => api(`/devices/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Signed out');
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const signOutOthers = useMutation({
|
||||
mutationFn: () => api('/devices/sign-out-others', { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Signed out everywhere else');
|
||||
refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const others = devices.data?.filter((d) => !d.current).length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Signed-in devices</CardTitle>
|
||||
<CardDescription>Each device stays signed in for about a year and renews whenever it’s used. Lost a phone? Sign it out here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2.5">
|
||||
{devices.isPending ? <Skeleton className="h-14" /> : null}
|
||||
{devices.data?.map((d) => {
|
||||
const Icon = /iPhone|Android/.test(d.deviceLabel) ? Smartphone : Laptop;
|
||||
return (
|
||||
<div key={d.id} className="flex items-center gap-3 rounded-xl border px-3.5 py-3">
|
||||
<Icon className="size-[18px] text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 truncate text-[14.5px] font-medium">
|
||||
{d.deviceLabel}
|
||||
{d.current ? <Badge tone="good">This device</Badge> : null}
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{d.current ? 'Active now' : `Last active ${timeAgo(d.lastSeenAt)}`} · signed in {timeAgo(d.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{!d.current ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => signOutOne.mutate(d.id)}>
|
||||
Sign out
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{others > 0 ? (
|
||||
<Button variant="outline" className="justify-self-start" loading={signOutOthers.isPending} onClick={() => signOutOthers.mutate()}>
|
||||
Sign out all other devices
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<FaceIdCard />
|
||||
<DevicesCard />
|
||||
<PasswordCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
web/src/pages/settings/SettingsLayout.tsx
Normal file
45
web/src/pages/settings/SettingsLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Archive, Info, Lightbulb, ShieldCheck, UserRound, Users } from 'lucide-react';
|
||||
import { NavLink, Outlet } from 'react-router';
|
||||
import { useConfig, useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const me = useCurrentUser();
|
||||
const { data: config } = useConfig();
|
||||
const sections = [
|
||||
{ to: 'profile', label: 'Profile', icon: UserRound, show: true },
|
||||
{ to: 'security', label: 'Sign-in & devices', icon: ShieldCheck, show: true },
|
||||
{ to: 'people', label: 'People', icon: Users, show: me.isAdmin },
|
||||
{ to: 'backups', label: 'Backups', icon: Archive, show: me.isAdmin },
|
||||
{ to: 'ideas', label: 'Ideas', icon: Lightbulb, show: me.isAdmin && !!config?.ideasEnabled },
|
||||
{ to: 'about', label: 'About', icon: Info, show: true },
|
||||
].filter((s) => s.show);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[220px_1fr] lg:gap-10">
|
||||
<nav
|
||||
aria-label="Settings"
|
||||
className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0 lg:sticky lg:top-24 lg:flex-col lg:self-start lg:overflow-visible"
|
||||
>
|
||||
{sections.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-10 shrink-0 items-center gap-2.5 rounded-xl px-3.5 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
||||
isActive && 'bg-card text-foreground shadow-soft ring-1 ring-border hover:bg-card',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-[17px]" strokeWidth={1.9} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/router.tsx
Normal file
74
web/src/router.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { lazy, type ReactNode } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { RequireAuth } from './components/RequireAuth';
|
||||
import { RouteError } from './components/RouteError';
|
||||
import { useCurrentUser } from './lib/session';
|
||||
import SetupPage from './pages/SetupPage';
|
||||
import SignInPage from './pages/SignInPage';
|
||||
|
||||
// Each screen loads its own code, so a phone's first visit stays small.
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const UpcomingPage = lazy(() => import('./pages/Upcoming'));
|
||||
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
|
||||
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
|
||||
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
|
||||
const ImportPage = lazy(() => import('./pages/ImportPage'));
|
||||
const MorePage = lazy(() => import('./pages/MorePage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
|
||||
const ProfilePage = lazy(() => import('./pages/settings/ProfilePage'));
|
||||
const SecurityPage = lazy(() => import('./pages/settings/SecurityPage'));
|
||||
const PeoplePage = lazy(() => import('./pages/settings/PeoplePage'));
|
||||
const BackupsPage = lazy(() => import('./pages/settings/BackupsPage'));
|
||||
const IdeasPage = lazy(() => import('./pages/settings/IdeasPage'));
|
||||
const AboutPage = lazy(() => import('./pages/settings/AboutPage'));
|
||||
|
||||
function AdminOnly({ children }: { children: ReactNode }) {
|
||||
const me = useCurrentUser();
|
||||
return me.isAdmin ? children : <Navigate to="/settings" replace />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/setup', element: <SetupPage />, errorElement: <RouteError /> },
|
||||
{ path: '/sign-in', element: <SignInPage />, errorElement: <RouteError /> },
|
||||
{
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{
|
||||
// Errors inside a screen show within the app frame, so navigation still works.
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'review', element: <ReviewPage /> },
|
||||
{ path: 'activity', element: <ActivityPage /> },
|
||||
{ path: 'import', element: <ImportPage /> },
|
||||
{ path: 'budget', element: <UpcomingPage screen="budget" /> },
|
||||
{ path: 'subscriptions', element: <UpcomingPage screen="subscriptions" /> },
|
||||
{ path: 'insights', element: <UpcomingPage screen="insights" /> },
|
||||
{ path: 'accounts', element: <AccountsPage /> },
|
||||
{ path: 'more', element: <MorePage /> },
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="profile" replace /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'security', element: <SecurityPage /> },
|
||||
{ path: 'people', element: <AdminOnly><PeoplePage /></AdminOnly> },
|
||||
{ path: 'backups', element: <AdminOnly><BackupsPage /></AdminOnly> },
|
||||
{ path: 'ideas', element: <AdminOnly><IdeasPage /></AdminOnly> },
|
||||
{ path: 'about', element: <AboutPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
]);
|
||||
158
web/src/styles.css
Normal file
158
web/src/styles.css
Normal file
@@ -0,0 +1,158 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Palette A, "Spotlight Indigo". Indigo drives every action; the amber-to-coral
|
||||
glow is the brand and appears only in the logo and hero moments. */
|
||||
:root {
|
||||
--background: #fafaf7;
|
||||
--foreground: #111827;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #111827;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #111827;
|
||||
--muted: #f2f2ee;
|
||||
--muted-foreground: #6b7280;
|
||||
--border: #e7e5df;
|
||||
--input: #dedcd5;
|
||||
--ring: #6366f1;
|
||||
--primary: #4f46e5;
|
||||
--primary-foreground: #ffffff;
|
||||
--accent: #eef0ff;
|
||||
--accent-foreground: #3730a3;
|
||||
--good: #16a34a;
|
||||
--warn: #d97706;
|
||||
--bad: #dc2626;
|
||||
--glow-1: #f59e0b;
|
||||
--glow-2: #f97362;
|
||||
--night: #0b1020;
|
||||
--shadow-color: 220 20% 20%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0b1020;
|
||||
--foreground: #e5e7eb;
|
||||
--card: #141b2e;
|
||||
--card-foreground: #e5e7eb;
|
||||
--popover: #182036;
|
||||
--popover-foreground: #e5e7eb;
|
||||
--muted: #1b2338;
|
||||
--muted-foreground: #9ca3af;
|
||||
--border: #26304a;
|
||||
--input: #2d3854;
|
||||
--ring: #818cf8;
|
||||
--primary: #818cf8;
|
||||
--primary-foreground: #0b1020;
|
||||
--accent: #1e2547;
|
||||
--accent-foreground: #c7d2fe;
|
||||
--good: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--bad: #f87171;
|
||||
--shadow-color: 225 50% 2%;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-good: var(--good);
|
||||
--color-warn: var(--warn);
|
||||
--color-bad: var(--bad);
|
||||
--color-glow-1: var(--glow-1);
|
||||
--color-glow-2: var(--glow-2);
|
||||
--color-night: var(--night);
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--shadow-soft: 0 1px 2px hsl(var(--shadow-color) / 0.06), 0 8px 24px -12px hsl(var(--shadow-color) / 0.18);
|
||||
--shadow-lift: 0 2px 4px hsl(var(--shadow-color) / 0.08), 0 18px 40px -16px hsl(var(--shadow-color) / 0.32);
|
||||
--animate-fade-up: fade-up 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--animate-sheet-up: sheet-up 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
opacity: 0.6;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
::selection {
|
||||
background: color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Grid items default to their content's width, so one long, truncated line
|
||||
could push a whole column past the edge of a phone screen. */
|
||||
.grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The brand glow: a soft pool of light behind hero moments. */
|
||||
@utility spotlight {
|
||||
background:
|
||||
radial-gradient(60% 80% at 50% 0%, color-mix(in oklab, var(--glow-1) 26%, transparent), transparent 70%),
|
||||
radial-gradient(50% 60% at 85% 10%, color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 70%);
|
||||
}
|
||||
|
||||
@utility pb-safe {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 0.5rem);
|
||||
}
|
||||
|
||||
@utility pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
@utility select-chevron {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 16px;
|
||||
}
|
||||
9
web/src/vite-env.d.ts
vendored
Normal file
9
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
12
web/tsconfig.json
Normal file
12
web/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
20
web/vite.config.ts
Normal file
20
web/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: { '/api': 'http://127.0.0.1:3000' },
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
chunkSizeWarningLimit: 300,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user