Replace admin alerts with inline errors and toasts
All checks were successful
docker / test (push) Successful in 37s
docker / build-and-push (push) Successful in 59s

This commit is contained in:
2026-05-21 16:25:43 +00:00
parent fd874c9499
commit 7cdf9b95f7
2 changed files with 55 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ export default function App() {
const [links, setLinks] = useState<LinkItem[]>([]); const [links, setLinks] = useState<LinkItem[]>([]);
const [page, setPage] = useState<Page>('loading'); const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null);
async function refresh() { async function refresh() {
const current = await api.request<SetupState>('/api/me'); const current = await api.request<SetupState>('/api/me');
@@ -33,6 +34,13 @@ export default function App() {
setLinks(await api.request<LinkItem[]>('/api/links')); 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(() => { useEffect(() => {
refresh().catch(() => setPage('setup')); refresh().catch(() => setPage('setup'));
}, []); }, []);
@@ -67,22 +75,16 @@ export default function App() {
onSave={async (payload, file, editingId) => { onSave={async (payload, file, editingId) => {
const fd = toFormData(payload, file); const fd = toFormData(payload, file);
const url = editingId ? `/api/links/${editingId}` : '/api/links'; const url = editingId ? `/api/links/${editingId}` : '/api/links';
try {
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd }); await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
} catch (err) { showToast(editingId ? 'Link updated.' : 'Link created.');
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Link name already exists')) {
alert('Link names must be unique.');
return;
}
throw err;
}
await refresh(); await refresh();
}} }}
onDelete={async (id) => { onDelete={async (id) => {
await api.request(`/api/links/${id}`, { method: 'DELETE' }); await api.request(`/api/links/${id}`, { method: 'DELETE' });
showToast('Link deleted.');
await refresh(); await refresh();
}} }}
showToast={showToast}
/> />
</div> </div>
</Shell> </Shell>
@@ -91,6 +93,7 @@ export default function App() {
return ( return (
<Shell> <Shell>
{toast ? <Toast message={toast.message} tone={toast.tone} /> : null}
<Dashboard <Dashboard
links={links} links={links}
query={query} query={query}
@@ -127,6 +130,14 @@ function Shell({ children }: { children: ReactNode }) {
return <div className="min-h-screen">{children}</div>; 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 }) { function Centered({ children }: { children: ReactNode }) {
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>; return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
} }
@@ -300,15 +311,18 @@ function AdminPage({
links, links,
onSave, onSave,
onDelete, onDelete,
showToast,
}: { }: {
links: LinkItem[]; links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>; onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
showToast: (message: string, tone?: 'success' | 'error') => void;
}) { }) {
const [form, setForm] = useState<LinkForm>(emptyForm()); const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState(''); const [restoreText, setRestoreText] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const startEdit = (link: LinkItem) => { const startEdit = (link: LinkItem) => {
setEditingId(link.id); setEditingId(link.id);
@@ -336,8 +350,18 @@ function AdminPage({
className="panel space-y-2" className="panel space-y-2"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
try {
await onSave(form, file, editingId ?? undefined); await onSave(form, file, editingId ?? undefined);
reset(); 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.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
@@ -354,6 +378,7 @@ function AdminPage({
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button> <button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null} {editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div> </div>
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
</form> </form>
<div className="panel space-y-2"> <div className="panel space-y-2">
<div className="text-xs text-slate-400">Backup / Restore</div> <div className="text-xs text-slate-400">Backup / Restore</div>
@@ -396,7 +421,7 @@ function AdminPage({
onClick={async () => { onClick={async () => {
const data = JSON.parse(restoreText); const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) }); await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
alert('Restore dry-run passed.'); showToast('Restore dry-run passed.');
}} }}
> >
Validate (dry-run) Validate (dry-run)
@@ -408,7 +433,7 @@ function AdminPage({
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return; if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
const data = JSON.parse(restoreText); const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) }); await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
alert('Restore applied.'); showToast('Restore applied.');
window.location.reload(); window.location.reload();
}} }}
> >

View File

@@ -74,3 +74,19 @@ body {
.admin-row { .admin-row {
@apply flex items-center justify-between gap-3 py-2; @apply flex items-center justify-between gap-3 py-2;
} }
.toast-wrap {
@apply pointer-events-none fixed right-4 top-4 z-50;
}
.toast {
@apply rounded-md border px-3 py-2 text-xs shadow-lg;
}
.toast-success {
@apply border-emerald-800 bg-emerald-950/95 text-emerald-200;
}
.toast-error {
@apply border-rose-800 bg-rose-950/95 text-rose-200;
}