This commit is contained in:
448
frontend/src/app.tsx
Normal file
448
frontend/src/app.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
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;
|
||||
};
|
||||
|
||||
const presetCards = [
|
||||
{ name: 'Sonarr', category: 'Arr*', url: 'http://sonarr:8989', description: 'TV library automation' },
|
||||
{ name: 'Radarr', category: 'Arr*', url: 'http://radarr:7878', description: 'Movie library automation' },
|
||||
{ name: 'Lidarr', category: 'Arr*', url: 'http://lidarr:8686', description: 'Music library automation' },
|
||||
{ name: 'Readarr', category: 'Arr*', url: 'http://readarr:8787', description: 'Book library automation' },
|
||||
{ name: 'Prowlarr', category: 'Arr*', url: 'http://prowlarr:9696', description: 'Indexer management' },
|
||||
{ name: 'Bazarr', category: 'Arr*', url: 'http://bazarr:6767', description: 'Subtitle management' },
|
||||
{ name: 'qBittorrent', category: 'Downloads', url: 'http://qbittorrent:8080', description: 'Torrent client' },
|
||||
{ name: 'Jellyfin', category: 'Media', url: 'http://jellyfin:8096', description: 'Media server' },
|
||||
{ name: 'Jellyseerr', category: 'Requests', url: 'http://jellyseerr:5055', description: 'Request management' },
|
||||
{ name: 'Overseerr', category: 'Requests', url: 'http://overseerr:5055', description: 'Request management' },
|
||||
];
|
||||
|
||||
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 [editing, setEditing] = useState<LinkItem | 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'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setPage('setup'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
refresh().catch(() => setPage('setup'));
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => links.filter((l) => l.enabled && `${l.name} ${l.description} ${l.category}`.toLowerCase().includes(query.toLowerCase())),
|
||||
[links, query]
|
||||
);
|
||||
|
||||
if (page === 'loading') return <Shell><Centered>Loading Jellomator...</Centered></Shell>;
|
||||
if (page === 'setup') return <SetupPage onDone={refresh} />;
|
||||
if (page === 'login') return <LoginPage onDone={refresh} />;
|
||||
|
||||
if (page === 'admin') {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-8">
|
||||
<AdminHeader
|
||||
currentUser={state?.current_user?.username ?? null}
|
||||
onBack={() => nav('/')}
|
||||
onLogout={async () => {
|
||||
await api.request('/api/logout', { method: 'POST' });
|
||||
nav('/');
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
<AdminPage
|
||||
links={links}
|
||||
editing={editing}
|
||||
setEditing={setEditing}
|
||||
onSave={async (payload, file) => {
|
||||
const fd = toFormData(payload, file);
|
||||
const url = editing ? `/api/links/${editing.id}` : '/api/links';
|
||||
await api.request(url, { method: editing ? 'PATCH' : 'POST', body: fd });
|
||||
setEditing(null);
|
||||
await refresh();
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
await api.request(`/api/links/${id}`, { method: 'DELETE' });
|
||||
if (editing?.id === id) setEditing(null);
|
||||
await refresh();
|
||||
}}
|
||||
onSeedPreset={async (preset) => {
|
||||
const fd = toFormData({
|
||||
name: preset.name,
|
||||
url: preset.url,
|
||||
description: preset.description,
|
||||
category: preset.category,
|
||||
icon_url: '',
|
||||
enabled: true,
|
||||
});
|
||||
await api.request('/api/links', { method: 'POST', body: fd });
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-8">
|
||||
<Hero
|
||||
currentUser={state?.current_user?.username ?? null}
|
||||
onAdmin={() => nav('/admin')}
|
||||
onLogout={state?.current_user ? async () => { await api.request('/api/logout', { method: 'POST' }); await refresh(); } : undefined}
|
||||
/>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-[320px_1fr]">
|
||||
<div className="glass rounded-3xl p-4">
|
||||
<input className="input" placeholder="Search services" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<div className="mt-4 space-y-2 text-sm text-slate-400">
|
||||
{['All', 'Arr*', 'Downloads', 'Media', 'Requests'].map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className="block w-full rounded-xl px-3 py-2 text-left hover:bg-white/5"
|
||||
onClick={() => setQuery(category === 'All' ? '' : category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((link) => <Card key={link.id} link={link} />)}
|
||||
{!filtered.length && <EmptyState title="No matching services" body="Try a different search or add a new link in the admin page." />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 Centered({ children }: { children: ReactNode }) {
|
||||
return <div className="grid min-h-screen place-items-center p-8 text-slate-400">{children}</div>;
|
||||
}
|
||||
|
||||
function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null; onAdmin: () => void; onLogout?: () => Promise<void> }) {
|
||||
return (
|
||||
<div className="glass relative overflow-hidden rounded-3xl p-6 md:p-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(244,63,94,.22),transparent_30%)]" />
|
||||
<div className="relative flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Jellomator</div>
|
||||
<h1 className="mt-3 text-4xl font-semibold md:text-6xl">Your media stack, one click away.</h1>
|
||||
<p className="mt-4 max-w-2xl text-slate-300">Curated links for Arr* services and custom tools, served from a single SQLite-backed container.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null}
|
||||
<button type="button" className="btn-primary" onClick={onAdmin}>Admin</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise<void> }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Protected admin</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold">Manage links and presets</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" className="btn-secondary" onClick={onBack}>Back to dashboard</button>
|
||||
<button type="button" className="btn-secondary" onClick={onLogout}>Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ link }: { link: LinkItem }) {
|
||||
return (
|
||||
<a href={link.url} target="_blank" rel="noreferrer" className="glass block rounded-3xl p-5 transition hover:-translate-y-1">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5">
|
||||
{link.icon_url ? <img src={link.icon_url} className="h-full w-full object-cover" alt="" /> : <span className="font-semibold text-accent-300">{link.name[0]}</span>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{link.name}</h3>
|
||||
<span className="rounded-full bg-accent-500/10 px-2 py-0.5 text-xs text-accent-200">{link.category}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-400">{link.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-accent-300">Launch service →</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="glass rounded-3xl p-10 text-slate-400">
|
||||
<h3 className="text-lg font-medium text-slate-100">{title}</h3>
|
||||
<p className="mt-2 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="glass w-full max-w-md space-y-4 rounded-3xl p-6"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(new FormData(e.currentTarget));
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{fields.map((f) => (
|
||||
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
|
||||
))}
|
||||
<button className="btn-primary w-full" type="submit">{action}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPage({
|
||||
links,
|
||||
editing,
|
||||
setEditing,
|
||||
onSave,
|
||||
onDelete,
|
||||
onSeedPreset,
|
||||
}: {
|
||||
links: LinkItem[];
|
||||
editing: LinkItem | null;
|
||||
setEditing: (link: LinkItem | null) => void;
|
||||
onSave: (payload: LinkForm, file?: File | null) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
onSeedPreset: (preset: (typeof presetCards)[number]) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<LinkForm>(emptyForm());
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setForm({
|
||||
name: editing.name,
|
||||
url: editing.url,
|
||||
description: editing.description,
|
||||
category: editing.category,
|
||||
icon_url: editing.icon_url ?? '',
|
||||
enabled: editing.enabled,
|
||||
});
|
||||
setFile(null);
|
||||
setPreview(editing.icon_url);
|
||||
} else {
|
||||
setForm(emptyForm());
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPreview(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
|
||||
<div className="glass rounded-3xl p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{editing ? 'Edit link' : 'Create link'}</h2>
|
||||
{editing ? <button type="button" className="btn-secondary px-3 py-2" onClick={() => setEditing(null)}>Reset</button> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
<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 fallback" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||
<input className="input file:mr-4 file:rounded-lg file:border-0 file:bg-accent-500 file:px-4 file:py-2 file:text-white" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="button" className="btn-primary" onClick={() => onSave(form, file)}>Save link</button>
|
||||
<button type="button" className="btn-secondary" onClick={() => setEditing(null)}>New</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-sm text-slate-400">Live preview</div>
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5">
|
||||
{preview ? <img src={preview} className="h-full w-full object-cover" alt="" /> : <span className="font-semibold text-accent-300">{form.name ? form.name[0] : 'S'}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{form.name || 'Service name'}</div>
|
||||
<div className="text-sm text-slate-400">{form.description || 'Description preview'}</div>
|
||||
<div className="mt-2 text-xs text-accent-300">{form.category || 'Category'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">{file ? `Selected file: ${file.name}` : 'File upload or icon URL fallback will be used for the service icon.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="glass rounded-3xl p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Seeded presets</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">These were inserted on first run and can be edited like any other link.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{presetCards.map((preset) => (
|
||||
<button key={preset.name} type="button" className="btn-secondary px-3 py-2 text-sm" onClick={() => onSeedPreset(preset)}>
|
||||
Add {preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-3xl p-6">
|
||||
<h2 className="text-xl font-semibold">Existing links</h2>
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-900">
|
||||
<tr>
|
||||
<th className="p-3">Name</th>
|
||||
<th className="p-3">Category</th>
|
||||
<th className="p-3">State</th>
|
||||
<th className="p-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{links.map((link) => (
|
||||
<tr key={link.id} className="border-t border-white/5">
|
||||
<td className="p-3">{link.name}</td>
|
||||
<td className="p-3 text-slate-400">{link.category}</td>
|
||||
<td className="p-3 text-slate-400">{link.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
<td className="p-3 text-right space-x-2">
|
||||
<button type="button" className="btn-secondary px-3 py-1" onClick={() => setEditing(link)}>Edit</button>
|
||||
<button type="button" className="btn-secondary px-3 py-1" onClick={() => onDelete(link.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyForm(): LinkForm {
|
||||
return {
|
||||
name: '',
|
||||
url: '',
|
||||
description: '',
|
||||
category: 'General',
|
||||
icon_url: '',
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user