Simplify UI and seed default Arr links
All checks were successful
docker / build-and-push (push) Successful in 48s
All checks were successful
docker / build-and-push (push) Successful in 48s
This commit is contained in:
@@ -23,9 +23,23 @@ DB_USER = os.getenv("DB_USER", "jellomator")
|
|||||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator")
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator")
|
||||||
DB_NAME = os.getenv("DB_NAME", "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 = 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=["*"])
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def db():
|
def db():
|
||||||
conn = pymysql.connect(
|
conn = pymysql.connect(
|
||||||
@@ -44,6 +58,24 @@ def db():
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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():
|
def init_db():
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
@@ -79,12 +111,17 @@ def init_db():
|
|||||||
updated_at varchar(64) not null
|
updated_at varchar(64) not null
|
||||||
) engine=InnoDB default charset=utf8mb4
|
) engine=InnoDB default charset=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
seed_default_links(c)
|
||||||
|
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
class SetupIn(BaseModel):
|
class SetupIn(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class LinkIn(BaseModel):
|
class LinkIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
@@ -93,10 +130,12 @@ class LinkIn(BaseModel):
|
|||||||
icon_url: Optional[str] = None
|
icon_url: Optional[str] = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LoginIn(BaseModel):
|
class LoginIn(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request):
|
def current_user(request: Request):
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
if not token:
|
if not token:
|
||||||
@@ -107,6 +146,7 @@ def current_user(request: Request):
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return row if row else None
|
return row if row else None
|
||||||
|
|
||||||
|
|
||||||
def require_admin(request: Request):
|
def require_admin(request: Request):
|
||||||
user = current_user(request)
|
user = current_user(request)
|
||||||
if not user:
|
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:
|
def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if exclude_id is None:
|
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:
|
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
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
def me(request: Request):
|
def me(request: Request):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
@@ -130,6 +171,7 @@ def me(request: Request):
|
|||||||
needs_setup = cur.fetchone()["count"] == 0
|
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:
|
||||||
@@ -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"))
|
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
|
||||||
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:
|
||||||
@@ -156,6 +199,7 @@ def login(inp: LoginIn):
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/api/logout")
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
@@ -167,6 +211,7 @@ def logout(request: Request):
|
|||||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/links")
|
@app.get("/api/links")
|
||||||
def links():
|
def links():
|
||||||
with db() as c:
|
with db() as c:
|
||||||
@@ -180,9 +225,10 @@ def links():
|
|||||||
icon_url = f"/api/links/{r['id']}/icon"
|
icon_url = f"/api/links/{r['id']}/icon"
|
||||||
elif r["icon_url"]:
|
elif r["icon_url"]:
|
||||||
icon_url = 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -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"]
|
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"]}
|
return {k: row[k] for k in ["id", "name", "url", "description", "category", "enabled", "icon_url"]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/links")
|
@app.post("/api/links")
|
||||||
def create_link(
|
def create_link(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -216,11 +263,14 @@ def create_link(
|
|||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
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)""",
|
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:
|
||||||
@@ -231,6 +281,7 @@ def link_icon(link_id: int):
|
|||||||
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")
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/links/{link_id}")
|
@app.delete("/api/links/{link_id}")
|
||||||
def delete_link(request: Request, link_id: int):
|
def delete_link(request: Request, link_id: int):
|
||||||
require_admin(request)
|
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,))
|
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}")
|
||||||
def update_link(
|
def update_link(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -262,16 +314,22 @@ def update_link(
|
|||||||
raise HTTPException(409, "Link name already exists")
|
raise HTTPException(409, "Link name already exists")
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
if icon_blob:
|
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""",
|
cur.execute(
|
||||||
(name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id))
|
"""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:
|
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""",
|
cur.execute(
|
||||||
(name,url,description,category,icon_url,int(enabled),now,link_id))
|
"""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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
if STATIC_DIR.exists():
|
if STATIC_DIR.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{path:path}")
|
@app.get("/{path:path}")
|
||||||
def spa(path: str):
|
def spa(path: str):
|
||||||
if path.startswith("api/"):
|
if path.startswith("api/"):
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default function App() {
|
|||||||
const [links, setLinks] = useState<LinkItem[]>([]);
|
const [links, setLinks] = useState<LinkItem[]>([]);
|
||||||
const [page, setPage] = useState<Page>('loading');
|
const [page, setPage] = useState<Page>('loading');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [editing, setEditing] = useState<LinkItem | null>(null);
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const current = await api.request<SetupState>('/api/me');
|
const current = await api.request<SetupState>('/api/me');
|
||||||
@@ -46,27 +45,14 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(
|
if (page === 'loading') return <Shell><Centered>Loading...</Centered></Shell>;
|
||||||
() => 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 <Shell><Centered>Loading Jellomator...</Centered></Shell>;
|
|
||||||
if (page === 'setup') return <SetupPage onDone={refresh} />;
|
if (page === 'setup') return <SetupPage onDone={refresh} />;
|
||||||
if (page === 'login') return <LoginPage onDone={refresh} />;
|
if (page === 'login') return <LoginPage onDone={refresh} />;
|
||||||
|
|
||||||
if (page === 'admin') {
|
if (page === 'admin') {
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="mx-auto max-w-7xl p-4 md:p-8">
|
<div className="container-wrap">
|
||||||
<AdminHeader
|
<AdminHeader
|
||||||
currentUser={state?.current_user?.username ?? null}
|
currentUser={state?.current_user?.username ?? null}
|
||||||
onBack={() => nav('/')}
|
onBack={() => nav('/')}
|
||||||
@@ -78,13 +64,11 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<AdminPage
|
<AdminPage
|
||||||
links={links}
|
links={links}
|
||||||
editing={editing}
|
onSave={async (payload, file, editingId) => {
|
||||||
setEditing={setEditing}
|
|
||||||
onSave={async (payload, file) => {
|
|
||||||
const fd = toFormData(payload, file);
|
const fd = toFormData(payload, file);
|
||||||
const url = editing ? `/api/links/${editing.id}` : '/api/links';
|
const url = editingId ? `/api/links/${editingId}` : '/api/links';
|
||||||
try {
|
try {
|
||||||
await api.request(url, { method: editing ? 'PATCH' : 'POST', body: fd });
|
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes('Link name already exists')) {
|
if (message.includes('Link name already exists')) {
|
||||||
@@ -93,12 +77,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
setEditing(null);
|
|
||||||
await refresh();
|
await refresh();
|
||||||
}}
|
}}
|
||||||
onDelete={async (id) => {
|
onDelete={async (id) => {
|
||||||
await api.request(`/api/links/${id}`, { method: 'DELETE' });
|
await api.request(`/api/links/${id}`, { method: 'DELETE' });
|
||||||
if (editing?.id === id) setEditing(null);
|
|
||||||
await refresh();
|
await refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -109,34 +91,17 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="mx-auto max-w-7xl p-4 md:p-8">
|
<Dashboard
|
||||||
<Hero
|
links={links}
|
||||||
currentUser={state?.current_user?.username ?? null}
|
query={query}
|
||||||
onAdmin={() => nav('/admin')}
|
setQuery={setQuery}
|
||||||
onLogout={state?.current_user ? async () => { await api.request('/api/logout', { method: 'POST' }); await refresh(); } : undefined}
|
canLogout={Boolean(state?.current_user)}
|
||||||
/>
|
onAdmin={() => nav('/admin')}
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-[320px_1fr]">
|
onLogout={async () => {
|
||||||
<div className="glass rounded-3xl p-4">
|
await api.request('/api/logout', { method: 'POST' });
|
||||||
<input className="input" placeholder="Search services" value={query} onChange={(e) => setQuery(e.target.value)} />
|
await refresh();
|
||||||
<div className="mt-4 space-y-2 text-sm text-slate-400">
|
}}
|
||||||
{categories.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>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,69 +128,105 @@ function Shell({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Centered({ children }: { children: ReactNode }) {
|
function Centered({ children }: { children: ReactNode }) {
|
||||||
return <div className="grid min-h-screen place-items-center p-8 text-slate-400">{children}</div>;
|
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null; onAdmin: () => void; onLogout?: () => Promise<void> }) {
|
function Dashboard({
|
||||||
|
links,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
canLogout,
|
||||||
|
onAdmin,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
links: LinkItem[];
|
||||||
|
query: string;
|
||||||
|
setQuery: (value: string) => void;
|
||||||
|
canLogout: boolean;
|
||||||
|
onAdmin: () => void;
|
||||||
|
onLogout: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
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<string, LinkItem[]>();
|
||||||
|
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 (
|
return (
|
||||||
<div className="glass relative overflow-hidden rounded-3xl p-6 md:p-10">
|
<div className="container-wrap">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(244,63,94,.22),transparent_30%)]" />
|
<header className="row-between">
|
||||||
<div className="relative flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
<h1 className="title-sm">Jellomator</h1>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Jellomator</div>
|
{canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null}
|
||||||
<h1 className="mt-3 text-4xl font-semibold md:text-6xl">Your media stack, one click away.</h1>
|
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button>
|
||||||
<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">
|
</header>
|
||||||
{currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null}
|
|
||||||
<button type="button" className="btn-primary" onClick={onAdmin}>Admin</button>
|
<input
|
||||||
</div>
|
className="input"
|
||||||
</div>
|
placeholder="Search links"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mt-4 space-y-4">
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<section key={group.category} className="panel">
|
||||||
|
<h2 className="section-title">{group.category}</h2>
|
||||||
|
<ul className="link-list">
|
||||||
|
{group.items.map((link) => (
|
||||||
|
<li key={link.id}>
|
||||||
|
<a href={link.url} target="_blank" rel="noreferrer" className="link-row">
|
||||||
|
<span className="link-name">{link.name}</span>
|
||||||
|
<span className="link-description">{link.description || link.url}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!grouped.length && <EmptyState title="No links found" body="No enabled links match your search." />}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise<void> }) {
|
function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string | null; onBack: () => void; onLogout: () => Promise<void> }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Protected admin</div>
|
<h1 className="title-sm">Manage links</h1>
|
||||||
<h1 className="mt-2 text-3xl font-semibold">Manage links</h1>
|
<p className="mt-1 text-xs text-slate-500">{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-2">
|
||||||
<button type="button" className="btn-secondary" onClick={onBack}>Back to dashboard</button>
|
<button type="button" className="btn-subtle" onClick={onBack}>Back</button>
|
||||||
<button type="button" className="btn-secondary" onClick={onLogout}>Logout</button>
|
<button type="button" className="btn-subtle" onClick={onLogout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</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 p-1">
|
|
||||||
{link.icon_url ? <img src={link.icon_url} className="h-full w-full object-contain" 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 }) {
|
function EmptyState({ title, body }: { title: string; body: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-3xl p-10 text-slate-400">
|
<div className="panel text-slate-500">
|
||||||
<h3 className="text-lg font-medium text-slate-100">{title}</h3>
|
<h3 className="text-sm font-medium text-slate-200">{title}</h3>
|
||||||
<p className="mt-2 text-sm">{body}</p>
|
<p className="mt-1 text-sm">{body}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,18 +274,18 @@ function AuthCard({
|
|||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center p-4">
|
<div className="grid min-h-screen place-items-center p-4">
|
||||||
<form
|
<form
|
||||||
className="glass w-full max-w-md space-y-4 rounded-3xl p-6"
|
className="panel w-full max-w-sm space-y-3"
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await onSubmit(new FormData(e.currentTarget));
|
await onSubmit(new FormData(e.currentTarget));
|
||||||
location.reload();
|
location.reload();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
<h1 className="title-sm">{title}</h1>
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
|
<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>
|
<button className="btn-subtle w-full" type="submit">{action}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -292,119 +293,77 @@ function AuthCard({
|
|||||||
|
|
||||||
function AdminPage({
|
function AdminPage({
|
||||||
links,
|
links,
|
||||||
editing,
|
|
||||||
setEditing,
|
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
links: LinkItem[];
|
links: LinkItem[];
|
||||||
editing: LinkItem | null;
|
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
|
||||||
setEditing: (link: LinkItem | null) => void;
|
|
||||||
onSave: (payload: LinkForm, file?: File | null) => Promise<void>;
|
|
||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: 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);
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const startEdit = (link: LinkItem) => {
|
||||||
if (editing) {
|
setEditingId(link.id);
|
||||||
setForm({
|
setFile(null);
|
||||||
name: editing.name,
|
setForm({
|
||||||
url: editing.url,
|
name: link.name,
|
||||||
description: editing.description,
|
url: link.url,
|
||||||
category: editing.category,
|
description: link.description,
|
||||||
icon_url: editing.icon_url ?? '',
|
category: link.category,
|
||||||
enabled: editing.enabled,
|
icon_url: link.icon_url ?? '',
|
||||||
});
|
enabled: link.enabled,
|
||||||
setFile(null);
|
});
|
||||||
setPreview(editing.icon_url);
|
};
|
||||||
} else {
|
|
||||||
setForm(emptyForm());
|
|
||||||
setFile(null);
|
|
||||||
setPreview(null);
|
|
||||||
}
|
|
||||||
}, [editing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const reset = () => {
|
||||||
if (!file) return;
|
setEditingId(null);
|
||||||
const objectUrl = URL.createObjectURL(file);
|
setFile(null);
|
||||||
setPreview(objectUrl);
|
setForm(emptyForm());
|
||||||
return () => URL.revokeObjectURL(objectUrl);
|
};
|
||||||
}, [file]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
|
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||||
<div className="glass rounded-3xl p-6">
|
<form
|
||||||
<div className="flex items-center justify-between">
|
className="panel space-y-2"
|
||||||
<h2 className="text-xl font-semibold">{editing ? 'Edit link' : 'Create link'}</h2>
|
onSubmit={async (event) => {
|
||||||
{editing ? <button type="button" className="btn-secondary px-3 py-2" onClick={() => setEditing(null)}>Reset</button> : null}
|
event.preventDefault();
|
||||||
|
await onSave(form, file, editingId ?? undefined);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
|
||||||
|
<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" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||||
|
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
|
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
|
||||||
|
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="panel">
|
||||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<ul className="admin-list">
|
||||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
{links.map((link) => (
|
||||||
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<li key={link.id} className="admin-row">
|
||||||
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
<div className="min-w-0">
|
||||||
<input className="input" value={form.icon_url} placeholder="icon URL fallback" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
<div className="truncate text-sm text-slate-100">{link.name}</div>
|
||||||
<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)} />
|
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
|
||||||
<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 p-1">
|
|
||||||
{preview ? <img src={preview} className="h-full w-full object-contain" alt="" /> : <span className="font-semibold text-accent-300">{form.name ? form.name[0] : 'S'}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex gap-2">
|
||||||
<div className="font-semibold">{form.name || 'Service name'}</div>
|
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
|
||||||
<div className="text-sm text-slate-400">{form.description || 'Description preview'}</div>
|
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
|
||||||
<div className="mt-2 text-xs text-accent-300">{form.category || 'Category'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
<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>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,16 +2,67 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root { color-scheme: dark; }
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-950 text-slate-100;
|
@apply bg-slate-950 text-slate-100;
|
||||||
background-image:
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
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%);
|
|
||||||
|
.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; }
|
|
||||||
|
|||||||
Reference in New Issue
Block a user