Build Jellomator MVP
All checks were successful
docker / build-and-push (push) Successful in 49s

This commit is contained in:
Space-Banane
2026-05-20 20:36:28 +02:00
parent ce0dc0880c
commit 3991a01ec7
18 changed files with 3830 additions and 0 deletions

448
frontend/src/app.tsx Normal file
View 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,
};
}

21
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,21 @@
export type SetupState = { needs_setup: boolean; current_user: { username: string } | null };
export type LinkItem = {
id: number;
name: string;
url: string;
description: string;
category: string;
enabled: boolean;
icon_url: string | null;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
credentials: 'include',
headers: init?.body instanceof FormData ? undefined : { 'Content-Type': 'application/json', ...(init?.headers || {}) },
...init,
});
if (!res.ok) throw new Error(await res.text());
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
export const api = { request };

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

17
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { color-scheme: dark; }
body {
@apply bg-slate-950 text-slate-100;
background-image:
radial-gradient(circle at top, rgba(244,63,94,.18), transparent 35%),
linear-gradient(180deg, rgba(15,23,42,1) 0%, rgba(2,6,23,1) 100%);
}
* { box-sizing: border-box; }
.glass { @apply bg-white/5 backdrop-blur-xl border border-white/10; }
.input { @apply w-full rounded-xl border border-white/10 bg-slate-900/80 px-4 py-3 text-slate-100 placeholder:text-slate-500 outline-none transition focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20; }
.btn { @apply inline-flex items-center justify-center rounded-xl px-4 py-3 font-medium transition; }
.btn-primary { @apply btn bg-accent-500 text-white hover:bg-accent-400 shadow-glow; }
.btn-secondary { @apply btn bg-white/5 text-slate-100 hover:bg-white/10 border border-white/10; }