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

View File

@@ -0,0 +1,22 @@
name: docker
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.REGISTRY }}/${{ secrets.IMAGE_NAME }}:latest
${{ secrets.REGISTRY }}/${{ secrets.IMAGE_NAME }}:${{ github.sha }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
data
dist
*.log
__pycache__

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package.json package.json
COPY vite.config.ts tsconfig.json tailwind.config.js postcss.config.cjs index.html ./
COPY frontend ./frontend
RUN npm install && npm run build:frontend
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY backend ./backend
COPY --from=frontend /app/frontend/dist ./frontend/dist
RUN pip install --no-cache-dir -r backend/requirements.txt
EXPOSE 6363
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "6363"]

View File

@@ -7,9 +7,11 @@ Dark, SQLite-backed dashboard for Arr* services and custom links.
- First-run admin setup - First-run admin setup
- Cookie-based admin auth - Cookie-based admin auth
- Public dashboard with search/filter - Public dashboard with search/filter
- Dedicated protected admin page at `/admin`
- Link CRUD backed by SQLite - Link CRUD backed by SQLite
- Icon blobs stored in SQLite - Icon blobs stored in SQLite
- Single-container deployment - Single-container deployment
- Seeded Arr* presets on first run
## Local Dev ## Local Dev
@@ -20,6 +22,8 @@ npm run dev
Backend runs on `http://localhost:6363`. Backend runs on `http://localhost:6363`.
Open `/admin` for the protected management page.
## Docker ## Docker
```bash ```bash
@@ -33,6 +37,7 @@ The app uses exactly one bind mount:
## SQLite ## SQLite
All data lives in SQLite, including uploaded icon blobs. There is no separate uploads directory. All data lives in SQLite, including uploaded icon blobs. There is no separate uploads directory.
The first-run setup also seeds editable defaults for Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr, qBittorrent, Jellyfin, Jellyseerr, and Overseerr.
## Gitea CI/CD ## Gitea CI/CD

225
backend/main.py Normal file
View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import secrets
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional
import bcrypt
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
DB_PATH = Path("/app/data/jellomator.sqlite")
STATIC_DIR = Path("frontend/dist")
SESSION_COOKIE = "jellomator_session"
app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
PRESET_LINKS = [
{"name": "Sonarr", "url": "http://sonarr:8989", "description": "TV library automation", "category": "Arr*", "enabled": True},
{"name": "Radarr", "url": "http://radarr:7878", "description": "Movie library automation", "category": "Arr*", "enabled": True},
{"name": "Lidarr", "url": "http://lidarr:8686", "description": "Music library automation", "category": "Arr*", "enabled": True},
{"name": "Readarr", "url": "http://readarr:8787", "description": "Book library automation", "category": "Arr*", "enabled": True},
{"name": "Prowlarr", "url": "http://prowlarr:9696", "description": "Indexer management", "category": "Arr*", "enabled": True},
{"name": "Bazarr", "url": "http://bazarr:6767", "description": "Subtitle management", "category": "Arr*", "enabled": True},
{"name": "qBittorrent", "url": "http://qbittorrent:8080", "description": "Torrent client", "category": "Downloads", "enabled": True},
{"name": "Jellyfin", "url": "http://jellyfin:8096", "description": "Media server", "category": "Media", "enabled": True},
{"name": "Jellyseerr", "url": "http://jellyseerr:5055", "description": "Request management", "category": "Requests", "enabled": True},
{"name": "Overseerr", "url": "http://overseerr:5055", "description": "Request management", "category": "Requests", "enabled": True},
]
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with db() as c:
c.executescript("""
create table if not exists users(id integer primary key, username text unique, password_hash blob, role text not null);
create table if not exists sessions(token text primary key, user_id integer not null, created_at text not null);
create table if not exists links(id integer primary key, name text not null, url text not null, description text, category text, icon_blob blob, icon_mime text, icon_url text, enabled integer not null default 1, created_at text not null, updated_at text not null);
""")
init_db()
class SetupIn(BaseModel):
username: str
password: str
class LinkIn(BaseModel):
name: str
url: str
description: str = ""
category: str = "General"
icon_url: Optional[str] = None
enabled: bool = True
class LoginIn(BaseModel):
username: str
password: str
def current_user(request: Request):
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
with db() as c:
row = c.execute("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=?", (token,)).fetchone()
return dict(row) if row else None
def require_admin(request: Request):
user = current_user(request)
if not user:
raise HTTPException(401, "Unauthorized")
return user
def seed_preset_links(conn: sqlite3.Connection):
now = datetime.utcnow().isoformat()
for preset in PRESET_LINKS:
conn.execute(
"""insert into links(name,url,description,category,enabled,created_at,updated_at)
values (?,?,?,?,?,?,?)""",
(preset["name"], preset["url"], preset["description"], preset["category"], int(preset["enabled"]), now, now),
)
@app.get("/api/me")
def me(request: Request):
with db() as c:
needs_setup = c.execute("select count(*) from users").fetchone()[0] == 0
return {"needs_setup": needs_setup, "current_user": current_user(request)}
@app.post("/api/setup")
def setup(inp: SetupIn):
with db() as c:
if c.execute("select count(*) from users").fetchone()[0] > 0:
raise HTTPException(400, "Setup already complete")
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
c.execute("insert into users(username,password_hash,role) values (?,?,?)", (inp.username, pw, "admin"))
seed_preset_links(c)
return {"ok": True}
@app.post("/api/login")
def login(inp: LoginIn):
with db() as c:
row = c.execute("select id,password_hash from users where username=?", (inp.username,)).fetchone()
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
raise HTTPException(401, "Invalid credentials")
token = secrets.token_urlsafe(32)
c.execute("insert into sessions(token,user_id,created_at) values (?,?,?)", (token, row["id"], datetime.utcnow().isoformat()))
response = JSONResponse({"ok": True})
response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/")
return response
@app.post("/api/logout")
def logout(request: Request):
token = request.cookies.get(SESSION_COOKIE)
with db() as c:
if token: c.execute("delete from sessions where token=?", (token,))
resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
@app.get("/api/links")
def links():
with db() as c:
rows = c.execute("select * from links order by enabled desc, category, name").fetchall()
out = []
for r in rows:
icon_url = None
if r["icon_blob"]:
icon_url = f"/api/links/{r['id']}/icon"
elif r["icon_url"]:
icon_url = r["icon_url"]
out.append({k: r[k] for k in ["id","name","url","description","category","enabled"]} | {"icon_url": icon_url})
return out
@app.get("/api/links/{link_id}")
def get_link(link_id: int):
with db() as c:
row = c.execute("select * from links where id=?", (link_id,)).fetchone()
if not row:
raise HTTPException(404, "Not found")
icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"]
return {k: row[k] for k in ["id", "name", "url", "description", "category", "enabled", "icon_url"]}
@app.post("/api/links")
def create_link(
request: Request,
name: str = Form(...),
url: str = Form(...),
description: str = Form(""),
category: str = Form("General"),
icon_url: str | None = Form(None),
enabled: bool = Form(True),
icon: UploadFile | None = File(None),
):
require_admin(request)
icon_blob = icon_mime = None
if icon:
icon_blob = icon.file.read()
icon_mime = icon.content_type
now = datetime.utcnow().isoformat()
with db() as c:
c.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (?,?,?,?,?,?,?,?,?,?)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now))
return {"ok": True}
@app.get("/api/links/{link_id}/icon")
def link_icon(link_id: int):
with db() as c:
row = c.execute("select icon_blob,icon_mime from links where id=?", (link_id,)).fetchone()
if not row or not row["icon_blob"]:
raise HTTPException(404, "Not found")
return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png")
@app.delete("/api/links/{link_id}")
def delete_link(request: Request, link_id: int):
require_admin(request)
with db() as c:
c.execute("delete from links where id=?", (link_id,))
return {"ok": True}
@app.patch("/api/links/{link_id}")
def update_link(
request: Request,
link_id: int,
name: str = Form(...),
url: str = Form(...),
description: str = Form(""),
category: str = Form("General"),
icon_url: str | None = Form(None),
enabled: bool = Form(True),
icon: UploadFile | None = File(None),
):
require_admin(request)
icon_blob = icon_mime = None
if icon:
icon_blob = icon.file.read()
icon_mime = icon.content_type
now = datetime.utcnow().isoformat()
with db() as c:
if icon_blob:
c.execute("""update links set name=?,url=?,description=?,category=?,icon_blob=?,icon_mime=?,icon_url=?,enabled=?,updated_at=? where id=?""",
(name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id))
else:
c.execute("""update links set name=?,url=?,description=?,category=?,icon_url=?,enabled=?,updated_at=? where id=?""",
(name,url,description,category,icon_url,int(enabled),now,link_id))
return {"ok": True}
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/{path:path}")
def spa(path: str):
if path.startswith("api/"):
raise HTTPException(404)
index = STATIC_DIR / "index.html"
if index.exists():
return FileResponse(index)
return HTMLResponse("<h1>Jellomator</h1>")

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.115.8
uvicorn[standard]==0.34.0
bcrypt==4.2.1
jinja2==3.1.5
pydantic==2.10.6
python-multipart==0.0.20

7
docker-compose.yml Normal file
View File

@@ -0,0 +1,7 @@
services:
jellomator:
build: .
ports:
- "6363:6363"
volumes:
- ./jellomator.sqlite:/app/data/jellomator.sqlite

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; }

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jellomator</title>
<script>
if (localStorage.theme === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');
</script>
</head>
<body class="bg-slate-950 text-slate-100 antialiased">
<div id="root"></div>
<script type="module" src="/frontend/src/main.tsx"></script>
</body>
</html>

2937
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "jellomator",
"private": true,
"version": "1.0.0",
"type": "module",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"scripts": {
"dev": "concurrently \"npm:dev:frontend\" \"npm:dev:backend\"",
"dev:frontend": "vite",
"dev:backend": "uvicorn backend.main:app --host 0.0.0.0 --port 6363 --reload",
"build": "npm run build:frontend",
"build:frontend": "vite build"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"concurrently": "^9.1.2",
"vite": "^5.4.19"
}
}

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

27
tailwind.config.js Normal file
View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './frontend/src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
accent: {
50: '#fff1f2',
100: '#ffe4e6',
200: '#fecdd3',
300: '#fda4af',
400: '#fb7185',
500: '#f43f5e',
600: '#e11d48',
700: '#be123c',
800: '#9f1239',
900: '#881337',
},
},
boxShadow: {
glow: '0 0 0 1px rgba(244,63,94,0.25), 0 20px 60px rgba(244,63,94,0.15)',
},
},
},
plugins: [],
};

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["frontend/src"],
"references": []
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'frontend/dist',
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'frontend/src'),
},
},
});