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 (
{dropping ? (
Drop to import
Statements from any of your banks
) : null}
);
}