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 ( {count > 999 ? '999+' : count} ); } function SideLink({ item, badge }: { item: NavItem; badge?: number }) { const Icon = item.icon; return ( 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', ) } > {item.label} ); } function Sidebar() { const { data: toReview } = useReviewCount(); return ( ); } 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 ( ); } /** 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 (
); } /** 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 (

{pageTitle(pathname)}

{me.access === 'full' ? ( ) : null} {config?.ideasEnabled ? : null}
}>
{dropping ? (

Drop to import

Statements from any of your banks

) : null}
); }