527 lines
18 KiB
TypeScript
527 lines
18 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();
|
|
}}
|
|
onReorder={async (items) => {
|
|
await api.request('/api/links/order', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ items: items.map((item, index) => ({ id: item.id, sort_order: index })) }),
|
|
});
|
|
showToast('Order saved.');
|
|
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,
|
|
onReorder,
|
|
showToast,
|
|
}: {
|
|
links: LinkItem[];
|
|
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
|
|
onDelete: (id: number) => Promise<void>;
|
|
onReorder: (items: LinkItem[]) => 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 [orderedLinks, setOrderedLinks] = useState<LinkItem[]>(links);
|
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
setOrderedLinks(links);
|
|
}, [links]);
|
|
|
|
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());
|
|
};
|
|
|
|
const moveBy = async (id: number, delta: number) => {
|
|
const index = orderedLinks.findIndex((link) => link.id === id);
|
|
const nextIndex = index + delta;
|
|
if (index < 0 || nextIndex < 0 || nextIndex >= orderedLinks.length) return;
|
|
const next = [...orderedLinks];
|
|
const [item] = next.splice(index, 1);
|
|
next.splice(nextIndex, 0, item);
|
|
setOrderedLinks(next);
|
|
await onReorder(next);
|
|
};
|
|
|
|
const handleDropOn = async (targetId: number) => {
|
|
if (draggingId === null || draggingId === targetId) return;
|
|
const from = orderedLinks.findIndex((link) => link.id === draggingId);
|
|
const to = orderedLinks.findIndex((link) => link.id === targetId);
|
|
if (from < 0 || to < 0) return;
|
|
const next = [...orderedLinks];
|
|
const [item] = next.splice(from, 1);
|
|
next.splice(to, 0, item);
|
|
setOrderedLinks(next);
|
|
setDraggingId(null);
|
|
await onReorder(next);
|
|
};
|
|
|
|
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">
|
|
{orderedLinks.map((link, index) => (
|
|
<li
|
|
key={link.id}
|
|
className="admin-row"
|
|
draggable
|
|
onDragStart={() => setDraggingId(link.id)}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={async () => {
|
|
await handleDropOn(link.id);
|
|
}}
|
|
>
|
|
<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={() => moveBy(link.id, -1)} disabled={index === 0}>Up</button>
|
|
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, 1)} disabled={index === orderedLinks.length - 1}>Down</button>
|
|
<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,
|
|
};
|
|
}
|