This commit is contained in:
22
.gitea/workflows/docker.yml
Normal file
22
.gitea/workflows/docker.yml
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
__pycache__
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
||||||
@@ -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
225
backend/main.py
Normal 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
6
backend/requirements.txt
Normal 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
7
docker-compose.yml
Normal 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
448
frontend/src/app.tsx
Normal 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
21
frontend/src/lib/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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
17
frontend/src/styles.css
Normal 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
16
index.html
Normal 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
2937
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal 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
22
tsconfig.json
Normal 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
16
vite.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user