Files
jellomator/frontend/src/app.tsx
Luna 7cdf9b95f7
All checks were successful
docker / test (push) Successful in 37s
docker / build-and-push (push) Successful in 59s
Replace admin alerts with inline errors and toasts
2026-05-21 16:25:43 +00:00

476 lines
16 KiB
TypeScript

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<SetupState | null>(null);
const [links, setLinks] = useState<LinkItem[]>([]);
const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null);
async function refresh() {
const current = await api.request<SetupState>('/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<LinkItem[]>('/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 <Shell><Centered>Loading...</Centered></Shell>;
if (page === 'setup') return <SetupPage onDone={refresh} />;
if (page === 'login') return <LoginPage onDone={refresh} />;
if (page === 'admin') {
return (
<Shell>
<div className="container-wrap">
<AdminHeader
currentUser={state?.current_user?.username ?? null}
onBack={() => nav('/')}
onLogout={async () => {
await api.request('/api/logout', { method: 'POST' });
nav('/');
await refresh();
}}
/>
<AdminPage
links={links}
onSave={async (payload, file, editingId) => {
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}
/>
</div>
</Shell>
);
}
return (
<Shell>
{toast ? <Toast message={toast.message} tone={toast.tone} /> : null}
<Dashboard
links={links}
query={query}
setQuery={setQuery}
canLogout={Boolean(state?.current_user)}
onAdmin={() => nav('/admin')}
onLogout={async () => {
await api.request('/api/logout', { method: 'POST' });
await refresh();
}}
/>
</Shell>
);
}
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 <div className="min-h-screen">{children}</div>;
}
function Toast({ message, tone }: { message: string; tone: 'success' | 'error' }) {
return (
<div className="toast-wrap" role="status" aria-live="polite">
<div className={`toast ${tone === 'error' ? 'toast-error' : 'toast-success'}`}>{message}</div>
</div>
);
}
function Centered({ children }: { children: ReactNode }) {
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
}
function Dashboard({
links,
query,
setQuery,
canLogout,
onAdmin,
onLogout,
}: {
links: LinkItem[];
query: string;
setQuery: (value: string) => void;
canLogout: boolean;
onAdmin: () => void;
onLogout: () => Promise<void>;
}) {
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<string, LinkItem[]>();
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 (
<div className="container-wrap">
<header className="row-between">
<h1 className="title-sm">Jellomator</h1>
<div className="flex items-center gap-2">
{canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button>
</div>
</header>
<input
className="input"
placeholder="Search links"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<main className="mt-4 space-y-4">
{grouped.map((group) => (
<section key={group.category} className="panel">
<h2 className="section-title">{group.category}</h2>
<ul className="link-list">
{group.items.map((link) => (
<li key={link.id}>
<a href={link.url} target="_blank" rel="noreferrer" className="link-row">
<span className="link-icon" aria-hidden="true">
{link.icon_url ? <img src={link.icon_url} alt="" className="link-icon-image" /> : <span>{link.name[0]}</span>}
</span>
<span className="min-w-0">
<span className="link-name">{link.name}</span>
<span className="link-description">{link.description || link.url}</span>
</span>
</a>
</li>
))}
</ul>
</section>
))}
{!grouped.length && <EmptyState title="No links found" body="No enabled links match your search." />}
</main>
</div>
);
}
function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise<void> }) {
return (
<div className="row-between">
<div>
<h1 className="title-sm">Manage links</h1>
<p className="mt-1 text-xs text-slate-500">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p>
</div>
<div className="flex gap-2">
<button type="button" className="btn-subtle" onClick={onBack}>Back</button>
<button type="button" className="btn-subtle" onClick={onLogout}>Logout</button>
</div>
</div>
);
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="panel text-slate-500">
<h3 className="text-sm font-medium text-slate-200">{title}</h3>
<p className="mt-1 text-sm">{body}</p>
</div>
);
}
function SetupPage({ onDone }: { onDone: () => Promise<void> }) {
return (
<AuthCard
title="First-run setup"
action="Create admin"
onSubmit={async (fd) => {
await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
await onDone();
}}
fields={['username', 'password']}
/>
);
}
function LoginPage({ onDone }: { onDone: () => Promise<void> }) {
return (
<AuthCard
title="Admin login"
action="Sign in"
onSubmit={async (fd) => {
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<void>;
}) {
return (
<div className="grid min-h-screen place-items-center p-4">
<form
className="panel w-full max-w-sm space-y-3"
onSubmit={async (e) => {
e.preventDefault();
await onSubmit(new FormData(e.currentTarget));
location.reload();
}}
>
<h1 className="title-sm">{title}</h1>
{fields.map((f) => (
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
))}
<button className="btn-subtle w-full" type="submit">{action}</button>
</form>
</div>
);
}
function AdminPage({
links,
onSave,
onDelete,
showToast,
}: {
links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
onDelete: (id: number) => Promise<void>;
showToast: (message: string, tone?: 'success' | 'error') => void;
}) {
const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState('');
const [submitError, setSubmitError] = useState<string | null>(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 (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<div className="space-y-4">
<form
className="panel space-y-2"
onSubmit={async (event) => {
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.');
}
}}
>
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enabled
</label>
<div className="flex gap-2 pt-1">
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div>
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
</form>
<div className="panel space-y-2">
<div className="text-xs text-slate-400">Backup / Restore</div>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const backup = await api.request<Record<string, unknown>>('/api/admin/backup');
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `jellomator-backup-${new Date().toISOString().replaceAll(':', '-')}.json`;
a.click();
URL.revokeObjectURL(a.href);
}}
>
Export backup
</button>
<input
type="file"
accept="application/json"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setRestoreText(await f.text());
}}
/>
</div>
<textarea
className="input min-h-32"
placeholder="Paste backup JSON here"
value={restoreText}
onChange={(e) => setRestoreText(e.target.value)}
/>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
showToast('Restore dry-run passed.');
}}
>
Validate (dry-run)
</button>
<button
type="button"
className="btn-subtle"
onClick={async () => {
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
showToast('Restore applied.');
window.location.reload();
}}
>
Apply restore
</button>
</div>
</div>
</div>
<div className="panel">
<ul className="admin-list">
{links.map((link) => (
<li key={link.id} className="admin-row">
<div className="min-w-0">
<div className="truncate text-sm text-slate-100">{link.name}</div>
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
</div>
<div className="flex gap-2">
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
</div>
</li>
))}
</ul>
</div>
</div>
);
}
function emptyForm(): LinkForm {
return {
name: '',
url: '',
description: '',
category: 'General',
icon_url: '',
enabled: true,
};
}