Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
230
web/src/components/AppShell.tsx
Normal file
230
web/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Ellipsis, Upload } from 'lucide-react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useReviewCount } from '@/lib/data';
|
||||
import { queueFiles } from '@/lib/pendingFiles';
|
||||
import { useConfig, useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
import { LogoMark, VersionTag } from './brand';
|
||||
import { IdeaButton } from './IdeaDialog';
|
||||
import { NAV, SETTINGS_NAV, type NavItem } from './nav';
|
||||
import { Button } from './ui/button';
|
||||
import { Skeleton, Tip } from './ui/misc';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
function pageTitle(pathname: string): string {
|
||||
if (pathname === '/') return 'Home';
|
||||
if (pathname.startsWith('/settings')) return 'Settings';
|
||||
if (pathname.startsWith('/more')) return 'More';
|
||||
if (pathname.startsWith('/import')) return 'Import';
|
||||
return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight';
|
||||
}
|
||||
|
||||
function CountBadge({ count, className }: { count: number | undefined; className?: string }) {
|
||||
if (!count) return null;
|
||||
return (
|
||||
<span className={cn('tabular flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground', className)}>
|
||||
{count > 999 ? '999+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-11 items-center gap-3 rounded-xl px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:justify-center lg:justify-start',
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="size-[19px] shrink-0" strokeWidth={1.9} />
|
||||
<CountBadge count={badge} className="absolute -right-2.5 -top-2 h-4 min-w-4 px-1 text-[10px] lg:hidden" />
|
||||
</span>
|
||||
<span className="flex-1 md:sr-only lg:not-sr-only">{item.label}</span>
|
||||
<CountBadge count={badge} className="hidden lg:flex" />
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const { data: toReview } = useReviewCount();
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-40 hidden flex-col border-r bg-card/70 backdrop-blur-xl md:flex md:w-[84px] lg:w-64">
|
||||
<Link to="/" className="flex h-16 items-center gap-2.5 px-6 md:justify-center lg:justify-start" aria-label="Spendlight home">
|
||||
<LogoMark />
|
||||
<span className="hidden text-[17px] font-semibold tracking-tight lg:inline">Spendlight</span>
|
||||
</Link>
|
||||
<nav className="flex flex-1 flex-col gap-1 px-3 py-2" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<SideLink key={item.to} item={item} badge={item.to === '/review' ? toReview : undefined} />
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 pb-4">
|
||||
<SideLink item={SETTINGS_NAV} />
|
||||
<div className="mt-3 px-3 md:text-center lg:text-left">
|
||||
<VersionTag />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneTabBar() {
|
||||
const { pathname } = useLocation();
|
||||
const { data: toReview } = useReviewCount();
|
||||
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
|
||||
const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors';
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/85 pb-safe backdrop-blur-xl md:hidden" aria-label="Main">
|
||||
<div className="mx-auto grid max-w-md grid-cols-5">
|
||||
{NAV.filter((n) => n.phone).map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => cn(tab, isActive && 'text-primary')}>
|
||||
<span className="relative">
|
||||
<Icon className="size-[22px]" strokeWidth={1.9} />
|
||||
{to === '/review' ? <CountBadge count={toReview} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" /> : null}
|
||||
</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
|
||||
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
|
||||
More
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** When the server is running a newer version than these screens, offer a reload. */
|
||||
function UpdateNotice() {
|
||||
const { data } = useConfig();
|
||||
useEffect(() => {
|
||||
if (!data || data.version === 'dev' || APP_VERSION === 'dev' || data.version === APP_VERSION) return;
|
||||
toast('A new version of Spendlight is ready', {
|
||||
id: 'update',
|
||||
description: `Version ${data.version}`,
|
||||
duration: Infinity,
|
||||
action: { label: 'Reload', onClick: () => window.location.reload() },
|
||||
});
|
||||
}, [data]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop statement files anywhere on a big screen to start importing them. */
|
||||
function useFileDrop(enabled: boolean) {
|
||||
const navigate = useNavigate();
|
||||
const [over, setOver] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let depth = 0;
|
||||
const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files');
|
||||
const enter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
depth++;
|
||||
setOver(true);
|
||||
};
|
||||
const leave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (!depth) setOver(false);
|
||||
};
|
||||
const overHandler = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
};
|
||||
const drop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
setOver(false);
|
||||
const files = [...(e.dataTransfer?.files ?? [])];
|
||||
if (files.length && !window.location.pathname.startsWith('/import')) {
|
||||
queueFiles(files);
|
||||
navigate('/import');
|
||||
}
|
||||
};
|
||||
window.addEventListener('dragenter', enter);
|
||||
window.addEventListener('dragleave', leave);
|
||||
window.addEventListener('dragover', overHandler);
|
||||
window.addEventListener('drop', drop);
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', enter);
|
||||
window.removeEventListener('dragleave', leave);
|
||||
window.removeEventListener('dragover', overHandler);
|
||||
window.removeEventListener('drop', drop);
|
||||
};
|
||||
}, [enabled, navigate]);
|
||||
return over;
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { data: config } = useConfig();
|
||||
const me = useCurrentUser();
|
||||
const { pathname } = useLocation();
|
||||
const dropping = useFileDrop(me.access === 'full' && !pathname.startsWith('/import'));
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar />
|
||||
<div className="md:pl-[84px] lg:pl-64">
|
||||
<header className="sticky top-0 z-30 border-b bg-background/80 pt-safe backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6 lg:px-8">
|
||||
<Link to="/" className="mr-1 md:hidden" aria-label="Spendlight home">
|
||||
<LogoMark className="size-7" />
|
||||
</Link>
|
||||
<h1 className="flex-1 truncate text-[17px] font-semibold tracking-tight">{pageTitle(pathname)}</h1>
|
||||
{me.access === 'full' ? (
|
||||
<Tip label="Import statements">
|
||||
<Button variant="ghost" size="icon" asChild aria-label="Import statements">
|
||||
<Link to="/import">
|
||||
<Upload />
|
||||
</Link>
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
{config?.ideasEnabled ? <IdeaButton /> : null}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 pb-32 pt-5 sm:px-6 md:pb-14 md:pt-8 lg:px-8">
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
<PhoneTabBar />
|
||||
<UpdateNotice />
|
||||
{dropping ? (
|
||||
<div className="pointer-events-none fixed inset-0 z-[60] flex items-center justify-center bg-night/50 p-8 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center rounded-3xl border-2 border-dashed border-white/50 px-16 py-12 text-center text-white">
|
||||
<Upload className="mb-3 size-10" strokeWidth={1.6} />
|
||||
<p className="text-xl font-semibold">Drop to import</p>
|
||||
<p className="mt-1 text-white/75">Statements from any of your banks</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user