From 975e0a4a7e9061ec1521f1dddf6fae9d672b0bf8 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 20 May 2026 21:03:40 +0200 Subject: [PATCH] Simplify UI and seed default Arr links --- backend/main.py | 76 +++++++-- frontend/src/app.tsx | 349 ++++++++++++++++++---------------------- frontend/src/styles.css | 71 ++++++-- 3 files changed, 282 insertions(+), 214 deletions(-) diff --git a/backend/main.py b/backend/main.py index 71923c7..9289942 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,9 +23,23 @@ DB_USER = os.getenv("DB_USER", "jellomator") DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator") DB_NAME = os.getenv("DB_NAME", "jellomator") +DEFAULT_LINKS = [ + {"name": "Jellyfin", "url": "http://localhost:8096", "description": "Media server", "category": "Media"}, + {"name": "Jellyseerr", "url": "http://localhost:5055", "description": "Request management", "category": "Media"}, + {"name": "Sonarr", "url": "http://localhost:8989", "description": "TV automation", "category": "Arr"}, + {"name": "Radarr", "url": "http://localhost:7878", "description": "Movie automation", "category": "Arr"}, + {"name": "Lidarr", "url": "http://localhost:8686", "description": "Music automation", "category": "Arr"}, + {"name": "Readarr", "url": "http://localhost:8787", "description": "Book automation", "category": "Arr"}, + {"name": "Bazarr", "url": "http://localhost:6767", "description": "Subtitle management", "category": "Arr"}, + {"name": "Prowlarr", "url": "http://localhost:9696", "description": "Indexer management", "category": "Arr"}, + {"name": "qBittorrent", "url": "http://localhost:8080", "description": "Torrent client", "category": "Download"}, + {"name": "SABnzbd", "url": "http://localhost:8085", "description": "Usenet client", "category": "Download"}, +] + app = FastAPI(title="Jellomator") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) + @contextmanager def db(): conn = pymysql.connect( @@ -44,6 +58,24 @@ def db(): finally: conn.close() + +def seed_default_links(conn): + now = datetime.utcnow().isoformat() + with conn.cursor() as cur: + for item in DEFAULT_LINKS: + cur.execute( + """insert into links(name,url,description,category,enabled,created_at,updated_at) + values (%s,%s,%s,%s,1,%s,%s) + on duplicate key update + url=values(url), + description=values(description), + category=values(category), + updated_at=values(updated_at) + """, + (item["name"], item["url"], item["description"], item["category"], now, now), + ) + + def init_db(): with db() as c: with c.cursor() as cur: @@ -79,12 +111,17 @@ def init_db(): updated_at varchar(64) not null ) engine=InnoDB default charset=utf8mb4 """) + seed_default_links(c) + + init_db() + class SetupIn(BaseModel): username: str password: str + class LinkIn(BaseModel): name: str url: str @@ -93,10 +130,12 @@ class LinkIn(BaseModel): 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: @@ -107,6 +146,7 @@ def current_user(request: Request): row = cur.fetchone() return row if row else None + def require_admin(request: Request): user = current_user(request) if not user: @@ -117,11 +157,12 @@ def require_admin(request: Request): def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool: with conn.cursor() as cur: if exclude_id is None: - cur.execute("select 1 from links where name=%s limit 1", (name,)) + cur.execute("select 1 from links where lower(name)=lower(%s) limit 1", (name,)) else: - cur.execute("select 1 from links where name=%s and id<>%s limit 1", (name, exclude_id)) + cur.execute("select 1 from links where lower(name)=lower(%s) and id<>%s limit 1", (name, exclude_id)) return cur.fetchone() is not None + @app.get("/api/me") def me(request: Request): with db() as c: @@ -130,6 +171,7 @@ def me(request: Request): needs_setup = cur.fetchone()["count"] == 0 return {"needs_setup": needs_setup, "current_user": current_user(request)} + @app.post("/api/setup") def setup(inp: SetupIn): with db() as c: @@ -141,6 +183,7 @@ def setup(inp: SetupIn): cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin")) return {"ok": True} + @app.post("/api/login") def login(inp: LoginIn): with db() as c: @@ -156,6 +199,7 @@ def login(inp: LoginIn): 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) @@ -167,6 +211,7 @@ def logout(request: Request): resp.delete_cookie(SESSION_COOKIE, path="/") return resp + @app.get("/api/links") def links(): with db() as c: @@ -180,9 +225,10 @@ def links(): 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}) + 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: @@ -194,6 +240,7 @@ def get_link(link_id: int): 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, @@ -216,11 +263,14 @@ def create_link( now = datetime.utcnow().isoformat() with db() as c: with c.cursor() as cur: - cur.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) + cur.execute( + """insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now)) + (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: @@ -231,6 +281,7 @@ def link_icon(link_id: int): 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) @@ -239,6 +290,7 @@ def delete_link(request: Request, link_id: int): cur.execute("delete from links where id=%s", (link_id,)) return {"ok": True} + @app.patch("/api/links/{link_id}") def update_link( request: Request, @@ -262,16 +314,22 @@ def update_link( raise HTTPException(409, "Link name already exists") with c.cursor() as cur: if icon_blob: - cur.execute("""update links set name=%s,url=%s,description=%s,category=%s,icon_blob=%s,icon_mime=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", - (name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id)) + cur.execute( + """update links set name=%s,url=%s,description=%s,category=%s,icon_blob=%s,icon_mime=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", + (name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, link_id), + ) else: - cur.execute("""update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", - (name,url,description,category,icon_url,int(enabled),now,link_id)) + cur.execute( + """update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", + (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/"): diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 3c1873f..15aeccf 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -16,7 +16,6 @@ export default function App() { const [links, setLinks] = useState([]); const [page, setPage] = useState('loading'); const [query, setQuery] = useState(''); - const [editing, setEditing] = useState(null); async function refresh() { const current = await api.request('/api/me'); @@ -46,27 +45,14 @@ export default function App() { 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] - ); - const categories = useMemo(() => { - const base = ['All']; - const visible = links - .filter((link) => link.enabled) - .map((link) => link.category) - .filter((value, index, array) => value && array.indexOf(value) === index); - return base.concat(visible.sort((a, b) => a.localeCompare(b))); - }, [links]); - - if (page === 'loading') return Loading Jellomator...; + if (page === 'loading') return Loading...; if (page === 'setup') return ; if (page === 'login') return ; if (page === 'admin') { return ( -
+
nav('/')} @@ -78,13 +64,11 @@ export default function App() { /> { + onSave={async (payload, file, editingId) => { const fd = toFormData(payload, file); - const url = editing ? `/api/links/${editing.id}` : '/api/links'; + const url = editingId ? `/api/links/${editingId}` : '/api/links'; try { - await api.request(url, { method: editing ? 'PATCH' : 'POST', body: fd }); + 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')) { @@ -93,12 +77,10 @@ export default function App() { } throw err; } - setEditing(null); await refresh(); }} onDelete={async (id) => { await api.request(`/api/links/${id}`, { method: 'DELETE' }); - if (editing?.id === id) setEditing(null); await refresh(); }} /> @@ -109,34 +91,17 @@ export default function App() { return ( -
- nav('/admin')} - onLogout={state?.current_user ? async () => { await api.request('/api/logout', { method: 'POST' }); await refresh(); } : undefined} - /> -
-
- setQuery(e.target.value)} /> -
- {categories.map((category) => ( - - ))} -
-
-
- {filtered.map((link) => )} - {!filtered.length && } -
-
-
+ nav('/admin')} + onLogout={async () => { + await api.request('/api/logout', { method: 'POST' }); + await refresh(); + }} + />
); } @@ -163,69 +128,105 @@ function Shell({ children }: { children: ReactNode }) { } function Centered({ children }: { children: ReactNode }) { - return
{children}
; + return
{children}
; } -function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null; onAdmin: () => void; onLogout?: () => Promise }) { +function Dashboard({ + links, + query, + setQuery, + canLogout, + onAdmin, + onLogout, +}: { + links: LinkItem[]; + query: string; + setQuery: (value: string) => void; + canLogout: boolean; + onAdmin: () => void; + onLogout: () => Promise; +}) { + 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(); + 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 ( -
-
-
-
-
Jellomator
-

Your media stack, one click away.

-

Curated links for Arr* services and custom tools, served from a single database-backed container.

+
+
+

Jellomator

+
+ {canLogout ? : null} +
-
- {currentUser ? : null} - -
-
+ + + setQuery(event.target.value)} + /> + +
+ {grouped.map((group) => ( +
+

{group.category}

+ +
+ ))} + + {!grouped.length && } +
); } function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise }) { return ( -
+
-
Protected admin
-

Manage links

-

{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}

+

Manage links

+

{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}

-
- - +
+ +
); } -function Card({ link }: { link: LinkItem }) { - return ( - -
-
- {link.icon_url ? : {link.name[0]}} -
-
-
-

{link.name}

- {link.category} -
-

{link.description}

-
-
-
Launch service →
-
- ); -} - function EmptyState({ title, body }: { title: string; body: string }) { return ( -
-

{title}

-

{body}

+
+

{title}

+

{body}

); } @@ -273,18 +274,18 @@ function AuthCard({ return (
{ e.preventDefault(); await onSubmit(new FormData(e.currentTarget)); location.reload(); }} > -

{title}

+

{title}

{fields.map((f) => ( ))} - +
); @@ -292,119 +293,77 @@ function AuthCard({ function AdminPage({ links, - editing, - setEditing, onSave, onDelete, }: { links: LinkItem[]; - editing: LinkItem | null; - setEditing: (link: LinkItem | null) => void; - onSave: (payload: LinkForm, file?: File | null) => Promise; + onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise; onDelete: (id: number) => Promise; }) { const [form, setForm] = useState(emptyForm()); const [file, setFile] = useState(null); - const [preview, setPreview] = useState(null); + const [editingId, setEditingId] = useState(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]); + 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, + }); + }; - useEffect(() => { - if (!file) return; - const objectUrl = URL.createObjectURL(file); - setPreview(objectUrl); - return () => URL.revokeObjectURL(objectUrl); - }, [file]); + const reset = () => { + setEditingId(null); + setFile(null); + setForm(emptyForm()); + }; return ( -
-
-
-

{editing ? 'Edit link' : 'Create link'}

- {editing ? : null} +
+
{ + event.preventDefault(); + await onSave(form, file, editingId ?? undefined); + reset(); + }} + > + setForm({ ...form, name: e.target.value })} required /> + setForm({ ...form, url: e.target.value })} required /> + setForm({ ...form, description: e.target.value })} /> + setForm({ ...form, category: e.target.value })} /> + setForm({ ...form, icon_url: e.target.value })} /> + setFile(e.target.files?.[0] ?? null)} /> + +
+ + {editingId ? : null}
+
-
- setForm({ ...form, name: e.target.value })} /> - setForm({ ...form, url: e.target.value })} /> - setForm({ ...form, description: e.target.value })} /> - setForm({ ...form, category: e.target.value })} /> - setForm({ ...form, icon_url: e.target.value })} /> - setFile(e.target.files?.[0] ?? null)} /> - -
- - -
-
- -
-
Live preview
-
-
-
- {preview ? : {form.name ? form.name[0] : 'S'}} +
+
    + {links.map((link) => ( +
  • +
    +
    {link.name}
    +
    {link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}
    -
    -
    {form.name || 'Service name'}
    -
    {form.description || 'Description preview'}
    -
    {form.category || 'Category'}
    +
    + +
    -
    -
    {file ? `Selected file: ${file.name}` : 'File upload or icon URL fallback will be used for the service icon.'}
    -
-
-
- -
-
-

Existing links

-
- - - - - - - - - - - {links.map((link) => ( - - - - - - - ))} - -
NameCategoryState
{link.name}{link.category}{link.enabled ? 'Enabled' : 'Disabled'} - - -
-
-
+ + ))} +
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2492945..e6aa245 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,16 +2,67 @@ @tailwind components; @tailwind utilities; -:root { color-scheme: dark; } +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + 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%); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +.container-wrap { + @apply mx-auto max-w-4xl p-4 md:p-6; +} + +.panel { + @apply rounded-xl border border-slate-800 bg-slate-900/80 p-4; +} + +.row-between { + @apply mb-4 flex items-center justify-between gap-3; +} + +.title-sm { + @apply text-base font-semibold tracking-wide; +} + +.section-title { + @apply mb-2 text-xs uppercase tracking-[0.18em] text-slate-400; +} + +.input { + @apply w-full rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 outline-none transition focus:border-slate-600; +} + +.btn-subtle { + @apply inline-flex items-center justify-center rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs text-slate-200 transition hover:border-slate-500 hover:text-white; +} + +.link-list { + @apply space-y-1; +} + +.link-row { + @apply block rounded-md px-2 py-2 transition hover:bg-slate-800; +} + +.link-name { + @apply block text-sm font-medium text-slate-100; +} + +.link-description { + @apply block truncate text-xs text-slate-500; +} + +.admin-list { + @apply divide-y divide-slate-800; +} + +.admin-row { + @apply flex items-center justify-between gap-3 py-2; } -* { 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; }