diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 327cbbd..83080a8 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -16,6 +16,7 @@ export default function App() { 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'); @@ -33,6 +34,13 @@ export default function App() { 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')); }, []); @@ -67,22 +75,16 @@ export default function App() { onSave={async (payload, file, editingId) => { const fd = toFormData(payload, file); const url = editingId ? `/api/links/${editingId}` : '/api/links'; - try { - await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd }); - } catch (err) { - 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 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} /> @@ -91,6 +93,7 @@ export default function App() { return ( + {toast ? : null} {children}; } +function Toast({ message, tone }: { message: string; tone: 'success' | 'error' }) { + return ( +
+
{message}
+
+ ); +} + function Centered({ children }: { children: ReactNode }) { return
{children}
; } @@ -300,15 +311,18 @@ 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); @@ -336,8 +350,18 @@ function AdminPage({ className="panel space-y-2" onSubmit={async (event) => { event.preventDefault(); - await onSave(form, file, editingId ?? undefined); - reset(); + 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 /> @@ -354,6 +378,7 @@ function AdminPage({ {editingId ? : null} + {submitError ?

{submitError}

: null}
Backup / Restore
@@ -396,7 +421,7 @@ function AdminPage({ onClick={async () => { const data = JSON.parse(restoreText); 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) @@ -408,7 +433,7 @@ function AdminPage({ 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 }) }); - alert('Restore applied.'); + showToast('Restore applied.'); window.location.reload(); }} > diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 28c135a..d7d58a5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -74,3 +74,19 @@ body { .admin-row { @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; +}