Remove preset services and move to MariaDB
All checks were successful
docker / build-and-push (push) Successful in 54s
All checks were successful
docker / build-and-push (push) Successful in 54s
This commit is contained in:
17
README.md
17
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Jellomator
|
# Jellomator
|
||||||
|
|
||||||
Dark, SQLite-backed dashboard for Arr* services and custom links.
|
Dark dashboard for Arr* services and custom links.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ Dark, SQLite-backed dashboard for Arr* services and custom links.
|
|||||||
- Cookie-based admin auth
|
- Cookie-based admin auth
|
||||||
- Public dashboard with search/filter
|
- Public dashboard with search/filter
|
||||||
- Dedicated protected admin page at `/admin`
|
- Dedicated protected admin page at `/admin`
|
||||||
- Link CRUD backed by SQLite
|
- Link CRUD backed by MariaDB
|
||||||
- Icon blobs stored in SQLite
|
- Icon blobs stored in the database
|
||||||
- Single-container deployment
|
- Single-container deployment
|
||||||
- Seeded Arr* presets on first run
|
- Admin-managed service links
|
||||||
|
|
||||||
## Local Dev
|
## Local Dev
|
||||||
|
|
||||||
@@ -30,14 +30,7 @@ Open `/admin` for the protected management page.
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The app uses exactly one bind mount:
|
The app expects a MariaDB instance configured through environment variables.
|
||||||
|
|
||||||
`./jellomator.sqlite:/app/data/jellomator.sqlite`
|
|
||||||
|
|
||||||
## SQLite
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
10
TODO.md
10
TODO.md
@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator.
|
|||||||
|
|
||||||
## P0
|
## P0
|
||||||
|
|
||||||
- Add a backup and restore flow for `jellomator.sqlite` in the admin UI.
|
- Add a backup and restore flow for the database in the admin UI.
|
||||||
- Let an admin download the current database.
|
- Let an admin download the current database.
|
||||||
- Let an admin upload a replacement database after confirmation.
|
- Let an admin upload a replacement database after confirmation.
|
||||||
- Validate that the uploaded file is SQLite before swapping it in.
|
- Validate the uploaded file before swapping it in.
|
||||||
- Add a basic health endpoint for Docker and orchestration.
|
- Add a basic health endpoint for Docker and orchestration.
|
||||||
- Return `200` when the app can read and write the database.
|
- Return `200` when the app can read and write the database.
|
||||||
- Return `503` if startup initialization or DB access fails.
|
- Return `503` if startup initialization or DB access fails.
|
||||||
@@ -21,7 +21,7 @@ Concrete follow-up work for Jellomator.
|
|||||||
## P1
|
## P1
|
||||||
|
|
||||||
- Add drag-and-drop ordering for service cards.
|
- Add drag-and-drop ordering for service cards.
|
||||||
- Persist display order in SQLite.
|
- Persist display order in the database.
|
||||||
- Support moving a card up, down, or to the top in admin.
|
- Support moving a card up, down, or to the top in admin.
|
||||||
- Add a featured/pinned flag for important links.
|
- Add a featured/pinned flag for important links.
|
||||||
- Keep pinned links above the normal list.
|
- Keep pinned links above the normal list.
|
||||||
@@ -41,9 +41,6 @@ Concrete follow-up work for Jellomator.
|
|||||||
|
|
||||||
## P2
|
## P2
|
||||||
|
|
||||||
- Add more presets for common self-hosted apps.
|
|
||||||
- Suggested first set: Paperless-ngx, Immich, Grafana, Home Assistant, Vaultwarden.
|
|
||||||
- Make each preset editable after insertion.
|
|
||||||
- Add JSON import/export for services.
|
- Add JSON import/export for services.
|
||||||
- Include metadata and icon blobs in the export format.
|
- Include metadata and icon blobs in the export format.
|
||||||
- Support importing a whole dashboard from a single file.
|
- Support importing a whole dashboard from a single file.
|
||||||
@@ -63,5 +60,4 @@ Concrete follow-up work for Jellomator.
|
|||||||
- Add a toast system for save, delete, and upload actions.
|
- Add a toast system for save, delete, and upload actions.
|
||||||
- Add Open Graph metadata for better link previews.
|
- Add Open Graph metadata for better link previews.
|
||||||
- Add structured JSON logging for auth and CRUD events.
|
- Add structured JSON logging for auth and CRUD events.
|
||||||
- Add a small command or script to reset seed data for local development.
|
|
||||||
- Add a CI verification step that builds the container image after publish.
|
- Add a CI verification step that builds the container image after publish.
|
||||||
|
|||||||
141
backend/main.py
141
backend/main.py
@@ -1,50 +1,83 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
import pymysql
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
DB_PATH = Path("/app/data/jellomator.sqlite")
|
|
||||||
STATIC_DIR = Path("frontend/dist")
|
STATIC_DIR = Path("frontend/dist")
|
||||||
SESSION_COOKIE = "jellomator_session"
|
SESSION_COOKIE = "jellomator_session"
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "mariadb")
|
||||||
|
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||||
|
DB_USER = os.getenv("DB_USER", "jellomator")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "jellomator")
|
||||||
|
|
||||||
app = FastAPI(title="Jellomator")
|
app = FastAPI(title="Jellomator")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
PRESET_LINKS = [
|
@contextmanager
|
||||||
{"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():
|
def db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = pymysql.connect(
|
||||||
conn.row_factory = sqlite3.Row
|
host=DB_HOST,
|
||||||
return conn
|
port=DB_PORT,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
autocommit=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"create database if not exists `{DB_NAME}` default charset=utf8mb4")
|
||||||
|
conn.select_db(DB_NAME)
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with db() as c:
|
with db() as c:
|
||||||
c.executescript("""
|
with c.cursor() as cur:
|
||||||
create table if not exists users(id integer primary key, username text unique, password_hash blob, role text not null);
|
cur.execute("""
|
||||||
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 users(
|
||||||
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);
|
id bigint auto_increment primary key,
|
||||||
|
username varchar(255) not null unique,
|
||||||
|
password_hash varbinary(255) not null,
|
||||||
|
role varchar(32) not null
|
||||||
|
) engine=InnoDB default charset=utf8mb4
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
create table if not exists sessions(
|
||||||
|
token varchar(255) primary key,
|
||||||
|
user_id bigint not null,
|
||||||
|
created_at varchar(64) not null,
|
||||||
|
index (user_id),
|
||||||
|
constraint sessions_user_fk foreign key (user_id) references users(id) on delete cascade
|
||||||
|
) engine=InnoDB default charset=utf8mb4
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
create table if not exists links(
|
||||||
|
id bigint auto_increment primary key,
|
||||||
|
name varchar(255) not null,
|
||||||
|
url text not null,
|
||||||
|
description text,
|
||||||
|
category varchar(255),
|
||||||
|
icon_blob longblob,
|
||||||
|
icon_mime varchar(255),
|
||||||
|
icon_url text,
|
||||||
|
enabled tinyint(1) not null default 1,
|
||||||
|
created_at varchar(64) not null,
|
||||||
|
updated_at varchar(64) not null
|
||||||
|
) engine=InnoDB default charset=utf8mb4
|
||||||
""")
|
""")
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
@@ -69,8 +102,10 @@ def current_user(request: Request):
|
|||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
with db() as c:
|
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()
|
with c.cursor() as cur:
|
||||||
return dict(row) if row else None
|
cur.execute("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s", (token,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row if row else None
|
||||||
|
|
||||||
def require_admin(request: Request):
|
def require_admin(request: Request):
|
||||||
user = current_user(request)
|
user = current_user(request)
|
||||||
@@ -78,39 +113,36 @@ def require_admin(request: Request):
|
|||||||
raise HTTPException(401, "Unauthorized")
|
raise HTTPException(401, "Unauthorized")
|
||||||
return user
|
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")
|
@app.get("/api/me")
|
||||||
def me(request: Request):
|
def me(request: Request):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
needs_setup = c.execute("select count(*) from users").fetchone()[0] == 0
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select count(*) as count from users")
|
||||||
|
needs_setup = cur.fetchone()["count"] == 0
|
||||||
return {"needs_setup": needs_setup, "current_user": current_user(request)}
|
return {"needs_setup": needs_setup, "current_user": current_user(request)}
|
||||||
|
|
||||||
@app.post("/api/setup")
|
@app.post("/api/setup")
|
||||||
def setup(inp: SetupIn):
|
def setup(inp: SetupIn):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
if c.execute("select count(*) from users").fetchone()[0] > 0:
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select count(*) as count from users")
|
||||||
|
if cur.fetchone()["count"] > 0:
|
||||||
raise HTTPException(400, "Setup already complete")
|
raise HTTPException(400, "Setup already complete")
|
||||||
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
|
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
|
||||||
c.execute("insert into users(username,password_hash,role) values (?,?,?)", (inp.username, pw, "admin"))
|
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
|
||||||
seed_preset_links(c)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.post("/api/login")
|
@app.post("/api/login")
|
||||||
def login(inp: LoginIn):
|
def login(inp: LoginIn):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
row = c.execute("select id,password_hash from users where username=?", (inp.username,)).fetchone()
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select id,password_hash from users where username=%s", (inp.username,))
|
||||||
|
row = cur.fetchone()
|
||||||
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
|
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
|
||||||
raise HTTPException(401, "Invalid credentials")
|
raise HTTPException(401, "Invalid credentials")
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
c.execute("insert into sessions(token,user_id,created_at) values (?,?,?)", (token, row["id"], datetime.utcnow().isoformat()))
|
with c.cursor() as cur:
|
||||||
|
cur.execute("insert into sessions(token,user_id,created_at) values (%s,%s,%s)", (token, row["id"], datetime.utcnow().isoformat()))
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/")
|
response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/")
|
||||||
return response
|
return response
|
||||||
@@ -119,7 +151,9 @@ def login(inp: LoginIn):
|
|||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
if token: c.execute("delete from sessions where token=?", (token,))
|
if token:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute("delete from sessions where token=%s", (token,))
|
||||||
resp = JSONResponse({"ok": True})
|
resp = JSONResponse({"ok": True})
|
||||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
return resp
|
return resp
|
||||||
@@ -127,7 +161,9 @@ def logout(request: Request):
|
|||||||
@app.get("/api/links")
|
@app.get("/api/links")
|
||||||
def links():
|
def links():
|
||||||
with db() as c:
|
with db() as c:
|
||||||
rows = c.execute("select * from links order by enabled desc, category, name").fetchall()
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select * from links order by enabled desc, category, name")
|
||||||
|
rows = cur.fetchall()
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
icon_url = None
|
icon_url = None
|
||||||
@@ -141,7 +177,9 @@ def links():
|
|||||||
@app.get("/api/links/{link_id}")
|
@app.get("/api/links/{link_id}")
|
||||||
def get_link(link_id: int):
|
def get_link(link_id: int):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
row = c.execute("select * from links where id=?", (link_id,)).fetchone()
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select * from links where id=%s", (link_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Not found")
|
raise HTTPException(404, "Not found")
|
||||||
icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"]
|
icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"]
|
||||||
@@ -165,15 +203,18 @@ def create_link(
|
|||||||
icon_mime = icon.content_type
|
icon_mime = icon.content_type
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
with db() as c:
|
with db() as c:
|
||||||
c.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
|
with c.cursor() as cur:
|
||||||
values (?,?,?,?,?,?,?,?,?,?)""",
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/links/{link_id}/icon")
|
@app.get("/api/links/{link_id}/icon")
|
||||||
def link_icon(link_id: int):
|
def link_icon(link_id: int):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
row = c.execute("select icon_blob,icon_mime from links where id=?", (link_id,)).fetchone()
|
with c.cursor() as cur:
|
||||||
|
cur.execute("select icon_blob,icon_mime from links where id=%s", (link_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
if not row or not row["icon_blob"]:
|
if not row or not row["icon_blob"]:
|
||||||
raise HTTPException(404, "Not found")
|
raise HTTPException(404, "Not found")
|
||||||
return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png")
|
return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png")
|
||||||
@@ -182,7 +223,8 @@ def link_icon(link_id: int):
|
|||||||
def delete_link(request: Request, link_id: int):
|
def delete_link(request: Request, link_id: int):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
c.execute("delete from links where id=?", (link_id,))
|
with c.cursor() as cur:
|
||||||
|
cur.execute("delete from links where id=%s", (link_id,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.patch("/api/links/{link_id}")
|
@app.patch("/api/links/{link_id}")
|
||||||
@@ -204,11 +246,12 @@ def update_link(
|
|||||||
icon_mime = icon.content_type
|
icon_mime = icon.content_type
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
with db() as c:
|
with db() as c:
|
||||||
|
with c.cursor() as cur:
|
||||||
if icon_blob:
|
if icon_blob:
|
||||||
c.execute("""update links set name=?,url=?,description=?,category=?,icon_blob=?,icon_mime=?,icon_url=?,enabled=?,updated_at=? where 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))
|
(name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id))
|
||||||
else:
|
else:
|
||||||
c.execute("""update links set name=?,url=?,description=?,category=?,icon_url=?,enabled=?,updated_at=? where 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))
|
(name,url,description,category,icon_url,int(enabled),now,link_id))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ bcrypt==4.2.1
|
|||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
pydantic==2.10.6
|
pydantic==2.10.6
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
pymysql==1.1.1
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: jellomator
|
||||||
|
MARIADB_USER: jellomator
|
||||||
|
MARIADB_PASSWORD: jellomator
|
||||||
|
MARIADB_ROOT_PASSWORD: root
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
|
||||||
jellomator:
|
jellomator:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "6363:6363"
|
- "6363:6363"
|
||||||
volumes:
|
depends_on:
|
||||||
- ./jellomator.sqlite:/app/data/jellomator.sqlite
|
- mariadb
|
||||||
|
environment:
|
||||||
|
DB_HOST: mariadb
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_USER: jellomator
|
||||||
|
DB_PASSWORD: jellomator
|
||||||
|
DB_NAME: jellomator
|
||||||
|
|||||||
@@ -11,19 +11,6 @@ type LinkForm = {
|
|||||||
enabled: boolean;
|
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() {
|
export default function App() {
|
||||||
const [state, setState] = useState<SetupState | null>(null);
|
const [state, setState] = useState<SetupState | null>(null);
|
||||||
const [links, setLinks] = useState<LinkItem[]>([]);
|
const [links, setLinks] = useState<LinkItem[]>([]);
|
||||||
@@ -97,18 +84,6 @@ export default function App() {
|
|||||||
if (editing?.id === id) setEditing(null);
|
if (editing?.id === id) setEditing(null);
|
||||||
await refresh();
|
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>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
@@ -182,7 +157,7 @@ function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null;
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Jellomator</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>
|
<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>
|
<p className="mt-4 max-w-2xl text-slate-300">Curated links for Arr* services and custom tools, served from a single database-backed container.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null}
|
{currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null}
|
||||||
@@ -198,7 +173,7 @@ function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string |
|
|||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Protected admin</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>
|
<h1 className="mt-2 text-3xl font-semibold">Manage links</h1>
|
||||||
<p className="mt-2 text-sm text-slate-400">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p>
|
<p className="mt-2 text-sm text-slate-400">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -304,14 +279,12 @@ function AdminPage({
|
|||||||
setEditing,
|
setEditing,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSeedPreset,
|
|
||||||
}: {
|
}: {
|
||||||
links: LinkItem[];
|
links: LinkItem[];
|
||||||
editing: LinkItem | null;
|
editing: LinkItem | null;
|
||||||
setEditing: (link: LinkItem | null) => void;
|
setEditing: (link: LinkItem | null) => void;
|
||||||
onSave: (payload: LinkForm, file?: File | null) => Promise<void>;
|
onSave: (payload: LinkForm, file?: File | null) => Promise<void>;
|
||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: number) => Promise<void>;
|
||||||
onSeedPreset: (preset: (typeof presetCards)[number]) => Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<LinkForm>(emptyForm());
|
const [form, setForm] = useState<LinkForm>(emptyForm());
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
@@ -387,22 +360,6 @@ function AdminPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="glass rounded-3xl p-6">
|
||||||
<h2 className="text-xl font-semibold">Existing links</h2>
|
<h2 className="text-xl font-semibold">Existing links</h2>
|
||||||
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10">
|
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
|||||||
Reference in New Issue
Block a user