import { ReactNode, useEffect, useMemo, useState } from 'react'; import { api, LinkItem, SetupState } from './lib/api'; type Page = 'loading' | 'setup' | 'login' | 'dashboard' | 'admin'; type LinkForm = { name: string; url: string; description: string; category: string; icon_url: string; enabled: boolean; }; export default function App() { const [state, setState] = useState(null); const [links, setLinks] = useState([]); const [page, setPage] = useState('loading'); const [query, setQuery] = useState(''); const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null); async function refresh() { const current = await api.request('/api/me'); setState(current); const path = window.location.pathname; if (current.needs_setup) { setPage('setup'); return; } if (!current.current_user) { setPage(path.startsWith('/admin') ? 'login' : 'dashboard'); } else { setPage(path.startsWith('/admin') ? 'admin' : 'dashboard'); } setLinks(await api.request('/api/links')); } function showToast(message: string, tone: 'success' | 'error' = 'success') { setToast({ message, tone }); window.setTimeout(() => { setToast((current) => (current?.message === message ? null : current)); }, 2500); } useEffect(() => { refresh().catch(() => setPage('setup')); }, []); useEffect(() => { const handlePopState = () => { refresh().catch(() => setPage('setup')); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); if (page === 'loading') return Loading...; if (page === 'setup') return ; if (page === 'login') return ; if (page === 'admin') { return (
nav('/')} onLogout={async () => { await api.request('/api/logout', { method: 'POST' }); nav('/'); await refresh(); }} /> { const fd = toFormData(payload, file); const url = editingId ? `/api/links/${editingId}` : '/api/links'; await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd }); showToast(editingId ? 'Link updated.' : 'Link created.'); await refresh(); }} onDelete={async (id) => { await api.request(`/api/links/${id}`, { method: 'DELETE' }); showToast('Link deleted.'); await refresh(); }} showToast={showToast} />
); } return ( {toast ? : null} nav('/admin')} onLogout={async () => { await api.request('/api/logout', { method: 'POST' }); await refresh(); }} /> ); } function nav(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); } function toFormData(payload: LinkForm, file?: File | null) { const fd = new FormData(); fd.append('name', payload.name); fd.append('url', payload.url); fd.append('description', payload.description); fd.append('category', payload.category); fd.append('icon_url', payload.icon_url); fd.append('enabled', String(payload.enabled)); if (file) fd.append('icon', file); return fd; } function Shell({ children }: { children: ReactNode }) { return
{children}
; } function Toast({ message, tone }: { message: string; tone: 'success' | 'error' }) { return (
{message}
); } function Centered({ children }: { children: ReactNode }) { return
{children}
; } function Dashboard({ links, query, setQuery, canLogout, onAdmin, onLogout, }: { links: LinkItem[]; query: string; setQuery: (value: string) => void; canLogout: boolean; onAdmin: () => void; onLogout: () => Promise; }) { const activeLinks = useMemo(() => links.filter((link) => link.enabled), [links]); const filtered = useMemo(() => { const text = query.trim().toLowerCase(); if (!text) return activeLinks; return activeLinks.filter((l) => `${l.name} ${l.description} ${l.category}`.toLowerCase().includes(text)); }, [activeLinks, query]); const grouped = useMemo(() => { const map = new Map(); for (const link of filtered) { const key = (link.category || 'General').trim() || 'General'; const list = map.get(key) ?? []; list.push(link); map.set(key, list); } return Array.from(map.entries()) .map(([category, items]) => ({ category, items: items.sort((a, b) => a.name.localeCompare(b.name)) })) .sort((a, b) => a.category.localeCompare(b.category)); }, [filtered]); return (

Jellomator

{canLogout ? : null}
setQuery(event.target.value)} />
{grouped.map((group) => (

{group.category}

))} {!grouped.length && }
); } function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise }) { return (

Manage links

{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}

); } function EmptyState({ title, body }: { title: string; body: string }) { return (

{title}

{body}

); } function SetupPage({ onDone }: { onDone: () => Promise }) { return ( { await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); await onDone(); }} fields={['username', 'password']} /> ); } function LoginPage({ onDone }: { onDone: () => Promise }) { return ( { await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); nav('/'); await onDone(); }} fields={['username', 'password']} /> ); } function AuthCard({ title, action, onSubmit, fields, }: { title: string; action: string; fields: string[]; onSubmit: (fd: FormData) => Promise; }) { return (
{ e.preventDefault(); await onSubmit(new FormData(e.currentTarget)); location.reload(); }} >

{title}

{fields.map((f) => ( ))}
); } function AdminPage({ links, onSave, onDelete, showToast, }: { links: LinkItem[]; onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise; onDelete: (id: number) => Promise; showToast: (message: string, tone?: 'success' | 'error') => void; }) { const [form, setForm] = useState(emptyForm()); const [file, setFile] = useState(null); const [editingId, setEditingId] = useState(null); const [restoreText, setRestoreText] = useState(''); const [submitError, setSubmitError] = useState(null); const startEdit = (link: LinkItem) => { setEditingId(link.id); setFile(null); setForm({ name: link.name, url: link.url, description: link.description, category: link.category, icon_url: link.icon_url ?? '', enabled: link.enabled, }); }; const reset = () => { setEditingId(null); setFile(null); setForm(emptyForm()); }; return (
{ event.preventDefault(); setSubmitError(null); try { await onSave(form, file, editingId ?? undefined); reset(); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes('Link name already exists')) { setSubmitError('Link names must be unique.'); return; } setSubmitError(message || 'Saving failed. Please try again.'); } }} > setForm({ ...form, name: e.target.value })} required /> setForm({ ...form, url: e.target.value })} required /> setForm({ ...form, description: e.target.value })} /> setForm({ ...form, category: e.target.value })} /> setForm({ ...form, icon_url: e.target.value })} /> setFile(e.target.files?.[0] ?? null)} />
{editingId ? : null}
{submitError ?

{submitError}

: null}
Backup / Restore
{ const f = e.target.files?.[0]; if (!f) return; setRestoreText(await f.text()); }} />