Initial commit: WatchStack v0.1.0
- FastAPI + SQLite backend with REST API - Polymorphic Media model (book + series) - Cover lookup via Open Library - Vanilla HTML/CSS/JS frontend (MAL-inspired) - Dark theme, status tabs, drawer detail, stats modal - 8 seed demo entries
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# App data (lokal generiert)
|
||||||
|
data/*.db
|
||||||
|
data/*.db-journal
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
|
data/backups/
|
||||||
|
data/exports/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Secrets / Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# WatchStack
|
||||||
|
|
||||||
|
Eine Watchlist für **Bücher, Serien & mehr** — inspiriert von MyAnimeList, aber mit eigenem Konzept.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📚 **Bücher**: Titel, Autor, Bände, Kapitel, Seiten, ISBN-Cover von Open Library, Status, Bewertung, Notizen, Genre, Tags
|
||||||
|
- 📺 **Serien**: Titel, Staffeln, Episoden, Sendezeitraum, Sender/Streaming, Status, Bewertung, Notizen, Genre, Tags
|
||||||
|
- 🔎 **Suche & Filter**: Volltext, Status, Genre, Tag, Bewertung, Sortierung
|
||||||
|
- 📊 **Dashboard**: Statistik pro Medium (Anzahl, verteilte Status, Durchschnittsbewertung)
|
||||||
|
- ⭐ **Bewertungen**: 1–10 Skala
|
||||||
|
- 🏷️ **Genres & Tags**: frei verwaltbar, vielen Einträgen zuweisbar
|
||||||
|
- 🌙 **Dark Theme** als Default
|
||||||
|
- 💾 **Lokal**: SQLite, single-user, kein Login nötig
|
||||||
|
- 🐳 Optional: Docker
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite
|
||||||
|
- **Frontend**: Vanilla HTML + CSS + JavaScript (kein Build-Step), HTMX-light Pattern
|
||||||
|
- **Cover**: Open Library API (Bücher), URL (Serien)
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Backend-Env
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2) Starten (legt data/watchstack.db automatisch an + seedet Beispieldaten)
|
||||||
|
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||||
|
|
||||||
|
# 3) Browser öffnen
|
||||||
|
xdg-open http://127.0.0.1:8000 # oder einfach manuell
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
watchlist/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI-App + Routes
|
||||||
|
│ ├── database.py # Engine + Session
|
||||||
|
│ ├── models.py # SQLAlchemy-Modelle
|
||||||
|
│ ├── schemas.py # Pydantic-Schemas
|
||||||
|
│ ├── crud.py # DB-Logik
|
||||||
|
│ ├── seed.py # Beispieldaten
|
||||||
|
│ ├── external.py # Open-Library-API
|
||||||
|
│ └── static/
|
||||||
|
│ ├── css/style.css
|
||||||
|
│ ├── js/app.js
|
||||||
|
│ └── img/
|
||||||
|
├── data/ # SQLite-DB (gitignored)
|
||||||
|
├── tests/
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Übersicht (Auszug)
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck |
|
||||||
|
| ------- | --------------------------------- | ------------------------------ |
|
||||||
|
| GET | `/api/media?type=book\|series` | Liste mit Filter & Suche |
|
||||||
|
| POST | `/api/media` | Neuen Eintrag anlegen |
|
||||||
|
| GET | `/api/media/{id}` | Detail |
|
||||||
|
| PATCH | `/api/media/{id}` | Ändern (Status, Bewertung …) |
|
||||||
|
| DELETE | `/api/media/{id}` | Löschen |
|
||||||
|
| GET | `/api/stats` | Aggregierte Stats |
|
||||||
|
| GET | `/api/lookup/book?isbn=…` | Cover per Open Library |
|
||||||
|
| GET | `/` | Web-UI |
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
"""Datenbank-Operationen (CRUD) für WatchStack."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, Optional, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Hilfsfunktionen --------------------------------------------------
|
||||||
|
|
||||||
|
def _get_or_create_named(db: Session, model, names: Iterable[str]) -> list:
|
||||||
|
"""Holt oder erstellt Genres/Tags anhand ihrer Namen."""
|
||||||
|
norm = sorted({n.strip() for n in names if n and n.strip()})
|
||||||
|
if not norm:
|
||||||
|
return []
|
||||||
|
existing = {x.name: x for x in db.scalars(select(model).where(model.name.in_(norm)))}
|
||||||
|
out = list(existing.values())
|
||||||
|
for n in norm:
|
||||||
|
if n not in existing:
|
||||||
|
obj = model(name=n)
|
||||||
|
db.add(obj)
|
||||||
|
out.append(obj)
|
||||||
|
db.flush()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_relations(media: models.Media, genres: list[str], tags: list[str], db: Session) -> None:
|
||||||
|
media.genres = _get_or_create_named(db, models.Genre, genres)
|
||||||
|
media.tags = _get_or_create_named(db, models.Tag, tags)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Lese-Operationen -------------------------------------------------
|
||||||
|
|
||||||
|
def list_media(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
kind: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
genre: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
sort: str = "updated_desc",
|
||||||
|
) -> Sequence[models.Media]:
|
||||||
|
stmt = select(models.Media).options(selectinload(models.Media.genres), selectinload(models.Media.tags))
|
||||||
|
|
||||||
|
conds = []
|
||||||
|
if kind:
|
||||||
|
conds.append(models.Media.kind == kind)
|
||||||
|
if status:
|
||||||
|
conds.append(models.Media.status == status)
|
||||||
|
if search:
|
||||||
|
like = f"%{search.lower()}%"
|
||||||
|
conds.append(
|
||||||
|
or_(
|
||||||
|
func.lower(models.Media.title).like(like),
|
||||||
|
func.lower(func.coalesce(models.Media.original_title, "")).like(like),
|
||||||
|
func.lower(func.coalesce(models.Media.author, "")).like(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if genre:
|
||||||
|
conds.append(models.Media.genres.any(models.Genre.name == genre))
|
||||||
|
if tag:
|
||||||
|
conds.append(models.Media.tags.any(models.Tag.name == tag))
|
||||||
|
|
||||||
|
if conds:
|
||||||
|
stmt = stmt.where(and_(*conds))
|
||||||
|
|
||||||
|
sort_map = {
|
||||||
|
"updated_desc": models.Media.updated_at.desc(),
|
||||||
|
"updated_asc": models.Media.updated_at.asc(),
|
||||||
|
"title_asc": models.Media.title.asc(),
|
||||||
|
"title_desc": models.Media.title.desc(),
|
||||||
|
"rating_desc": models.Media.rating.desc().nulls_last(),
|
||||||
|
"rating_asc": models.Media.rating.asc().nulls_last(),
|
||||||
|
"year_desc": models.Media.release_year.desc().nulls_last(),
|
||||||
|
"year_asc": models.Media.release_year.asc().nulls_last(),
|
||||||
|
}
|
||||||
|
stmt = stmt.order_by(sort_map.get(sort, models.Media.updated_at.desc()))
|
||||||
|
return db.scalars(stmt).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_media(db: Session, media_id: int) -> Optional[models.Media]:
|
||||||
|
stmt = (
|
||||||
|
select(models.Media)
|
||||||
|
.options(selectinload(models.Media.genres), selectinload(models.Media.tags))
|
||||||
|
.where(models.Media.id == media_id)
|
||||||
|
)
|
||||||
|
return db.scalars(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Schreib-Operationen ----------------------------------------------
|
||||||
|
|
||||||
|
def create_media(db: Session, payload: schemas.MediaCreate) -> models.Media:
|
||||||
|
media = models.Media(**payload.model_dump(exclude={"genres", "tags"}))
|
||||||
|
db.add(media)
|
||||||
|
db.flush()
|
||||||
|
_attach_relations(media, payload.genres, payload.tags, db)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
def update_media(db: Session, media: models.Media, payload: schemas.MediaUpdate) -> models.Media:
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
rel_fields = {"genres", "tags"}
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in rel_fields:
|
||||||
|
continue
|
||||||
|
setattr(media, k, v)
|
||||||
|
if "genres" in data:
|
||||||
|
media.genres = _get_or_create_named(db, models.Genre, data["genres"] or [])
|
||||||
|
if "tags" in data:
|
||||||
|
media.tags = _get_or_create_named(db, models.Tag, data["tags"] or [])
|
||||||
|
db.commit()
|
||||||
|
db.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
def delete_media(db: Session, media: models.Media) -> None:
|
||||||
|
db.delete(media)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def bump_progress(db: Session, media: models.Media, payload: schemas.ProgressUpdate) -> models.Media:
|
||||||
|
"""Setzt/erhöht Fortschritt, je nach Medium."""
|
||||||
|
if media.kind == "book":
|
||||||
|
total = media.total_chapters or media.total_pages or media.total_volumes
|
||||||
|
attr = (
|
||||||
|
"chapters_read"
|
||||||
|
if media.total_chapters
|
||||||
|
else "pages_read"
|
||||||
|
if media.total_pages
|
||||||
|
else "volumes_read"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = media.total_episodes
|
||||||
|
attr = "episodes_watched"
|
||||||
|
|
||||||
|
current = getattr(media, attr) or 0
|
||||||
|
if payload.set_to is not None:
|
||||||
|
new_value = payload.set_to
|
||||||
|
else:
|
||||||
|
new_value = current + payload.delta
|
||||||
|
if total is not None and total >= 0:
|
||||||
|
new_value = max(0, min(new_value, total))
|
||||||
|
setattr(media, attr, new_value)
|
||||||
|
|
||||||
|
# Auto-Status: 0 -> plan, total -> done, sonst reading
|
||||||
|
if total and new_value == 0:
|
||||||
|
media.status = "plan"
|
||||||
|
elif total and new_value >= total:
|
||||||
|
media.status = "done"
|
||||||
|
if media.kind == "series" and not media.end_date:
|
||||||
|
from datetime import date
|
||||||
|
media.end_date = date.today()
|
||||||
|
elif media.status == "plan":
|
||||||
|
media.status = "reading"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Statistik --------------------------------------------------------
|
||||||
|
|
||||||
|
def stats(db: Session) -> schemas.StatsResponse:
|
||||||
|
def per_kind(kind: str) -> schemas.StatsPerKind:
|
||||||
|
items = db.scalars(select(models.Media).where(models.Media.kind == kind)).all()
|
||||||
|
total = len(items)
|
||||||
|
avg = (
|
||||||
|
round(sum(m.rating for m in items if m.rating is not None) /
|
||||||
|
max(1, sum(1 for m in items if m.rating is not None)), 2)
|
||||||
|
if any(m.rating is not None for m in items)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
by_status: dict[str, int] = {s: 0 for s in models.STATUS_VALUES}
|
||||||
|
for m in items:
|
||||||
|
by_status[m.status] = by_status.get(m.status, 0) + 1
|
||||||
|
return schemas.StatsPerKind(
|
||||||
|
kind=kind,
|
||||||
|
total=total,
|
||||||
|
avg_rating=avg,
|
||||||
|
by_status=[schemas.StatsBucket(status=k, count=v) for k, v in by_status.items()],
|
||||||
|
)
|
||||||
|
|
||||||
|
books = per_kind("book")
|
||||||
|
series = per_kind("series")
|
||||||
|
return schemas.StatsResponse(
|
||||||
|
books=books,
|
||||||
|
series=series,
|
||||||
|
total_entries=books.total + series.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Hilfslisten ------------------------------------------------------
|
||||||
|
|
||||||
|
def list_genres(db: Session) -> Sequence[models.Genre]:
|
||||||
|
return db.scalars(select(models.Genre).order_by(models.Genre.name)).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_tags(db: Session) -> Sequence[models.Tag]:
|
||||||
|
return db.scalars(select(models.Tag).order_by(models.Tag.name)).all()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Datenbank-Engine und Session-Verwaltung."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
# SQLite-Datei liegt im data/-Ordner (gitignored).
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
DB_PATH = DATA_DIR / "watchstack.db"
|
||||||
|
|
||||||
|
DATABASE_URL = f"sqlite:///{DB_PATH}"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
future=True,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Basisklasse für alle ORM-Modelle."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""FastAPI-Dependency: liefert eine Session, schließt sie danach."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Erstellt alle Tabellen, falls noch nicht vorhanden."""
|
||||||
|
# noqa: F401 – Modelle müssen importiert sein, damit sie bei Base.metadata registriert sind.
|
||||||
|
from app import models # type: ignore[F401]
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Externe Cover-/Metadaten-Quellen (Open Library)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OL_SEARCH = "https://openlibrary.org/search.json"
|
||||||
|
OL_COVER = "https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_by_isbn(isbn: str, *, timeout: float = 8.0) -> Optional[dict]:
|
||||||
|
"""Sucht Buchdaten anhand ISBN via Open Library."""
|
||||||
|
if not isbn:
|
||||||
|
return None
|
||||||
|
isbn = isbn.strip().replace("-", "").replace(" ", "")
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
r = client.get(OL_SEARCH, params={"isbn": isbn, "limit": 1})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
log.warning("Open-Library-Suche fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
docs = data.get("docs") or []
|
||||||
|
if not docs:
|
||||||
|
return None
|
||||||
|
d = docs[0]
|
||||||
|
return {
|
||||||
|
"title": d.get("title"),
|
||||||
|
"original_title": d.get("title"),
|
||||||
|
"author": ", ".join(d.get("author_name") or []) or None,
|
||||||
|
"release_year": d.get("first_publish_year"),
|
||||||
|
"cover_url": OL_COVER.format(isbn=isbn) if isbn else None,
|
||||||
|
"total_pages": d.get("number_of_pages_median"),
|
||||||
|
"description": None, # OL liefert hier keine handliche Kurzbeschreibung
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_by_title(title: str, *, timeout: float = 8.0) -> Optional[dict]:
|
||||||
|
"""Sucht Buchdaten anhand Titel (Fallback)."""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
r = client.get(OL_SEARCH, params={"title": title, "limit": 1})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
log.warning("Open-Library-Suche fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
docs = data.get("docs") or []
|
||||||
|
if not docs:
|
||||||
|
return None
|
||||||
|
d = docs[0]
|
||||||
|
isbn = None
|
||||||
|
isbns = d.get("isbn") or []
|
||||||
|
if isbns:
|
||||||
|
isbn = isbns[0]
|
||||||
|
return {
|
||||||
|
"title": d.get("title"),
|
||||||
|
"author": ", ".join(d.get("author_name") or []) or None,
|
||||||
|
"release_year": d.get("first_publish_year"),
|
||||||
|
"cover_url": OL_COVER.format(isbn=isbn) if isbn else None,
|
||||||
|
"total_pages": d.get("number_of_pages_median"),
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
"""FastAPI-App: REST-API + statisches Frontend."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import crud, schemas
|
||||||
|
from app.database import get_db, init_db
|
||||||
|
from app.external import lookup_book_by_isbn, lookup_book_by_title
|
||||||
|
from app.seed import seed_if_empty
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("watchstack")
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="WatchStack",
|
||||||
|
description="Watchlist für Bücher, Serien & mehr.",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _startup() -> None:
|
||||||
|
init_db()
|
||||||
|
with next(get_db()) as db:
|
||||||
|
seed_if_empty(db)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Statische Dateien & UI ------------------------------------------
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def root(request: Request) -> HTMLResponse:
|
||||||
|
index = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
return HTMLResponse(index)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Health -----------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/health", tags=["meta"])
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "ok", "service": "watchstack", "version": app.version}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Metadaten-Lookup -------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/lookup/book", tags=["lookup"])
|
||||||
|
def lookup_book(
|
||||||
|
isbn: Optional[str] = Query(None, description="ISBN-10 oder ISBN-13"),
|
||||||
|
title: Optional[str] = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Schlägt Buchdaten extern nach. ISBN hat Vorrang."""
|
||||||
|
if isbn:
|
||||||
|
result = lookup_book_by_isbn(isbn)
|
||||||
|
elif title:
|
||||||
|
result = lookup_book_by_title(title)
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "isbn oder title erforderlich")
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "Keine Daten gefunden")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Media ------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/media", response_model=list[schemas.MediaOut], tags=["media"])
|
||||||
|
def api_list_media(
|
||||||
|
kind: Optional[str] = Query(None, pattern="^(book|series)$"),
|
||||||
|
status: Optional[str] = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
genre: Optional[str] = Query(None),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
sort: str = Query("updated_desc"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items = crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/media", response_model=schemas.MediaOut, status_code=201, tags=["media"])
|
||||||
|
def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)):
|
||||||
|
return crud.create_media(db, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
|
||||||
|
def api_get_media(media_id: int, db: Session = Depends(get_db)):
|
||||||
|
m = crud.get_media(db, media_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
|
||||||
|
def api_update_media(
|
||||||
|
media_id: int,
|
||||||
|
payload: schemas.MediaUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
m = crud.get_media(db, media_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
return crud.update_media(db, m, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/media/{media_id}", status_code=204, tags=["media"])
|
||||||
|
def api_delete_media(media_id: int, db: Session = Depends(get_db)):
|
||||||
|
m = crud.get_media(db, media_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
crud.delete_media(db, m)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/media/{media_id}/progress", response_model=schemas.MediaOut, tags=["media"])
|
||||||
|
def api_progress(
|
||||||
|
media_id: int,
|
||||||
|
payload: schemas.ProgressUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
m = crud.get_media(db, media_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
return crud.bump_progress(db, m, payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Taxonomien -------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/genres", response_model=list[schemas.GenreOut], tags=["taxonomy"])
|
||||||
|
def api_genres(db: Session = Depends(get_db)):
|
||||||
|
return crud.list_genres(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tags", response_model=list[schemas.TagOut], tags=["taxonomy"])
|
||||||
|
def api_tags(db: Session = Depends(get_db)):
|
||||||
|
return crud.list_tags(db)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Statistik --------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/stats", response_model=schemas.StatsResponse, tags=["stats"])
|
||||||
|
def api_stats(db: Session = Depends(get_db)):
|
||||||
|
return crud.stats(db)
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
"""SQLAlchemy-Modelle für WatchStack.
|
||||||
|
|
||||||
|
Zentrale Idee: ein polymorphes ``Media``-Objekt mit ``kind`` (book/series), das
|
||||||
|
optionale, mediumspezifische Felder als NULL-sparende Spalten mitführt. So
|
||||||
|
müssen wir keine separaten Tabellen für Bücher vs. Serien pflegen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
CheckConstraint,
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Assoziationstabellen (n:m) ---------------------------------------
|
||||||
|
|
||||||
|
media_genres = Table(
|
||||||
|
"media_genres",
|
||||||
|
Base.metadata,
|
||||||
|
Column("media_id", Integer, ForeignKey("media.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("genre_id", Integer, ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
media_tags = Table(
|
||||||
|
"media_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column("media_id", Integer, ForeignKey("media.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Genre(Base):
|
||||||
|
__tablename__ = "genres"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Genre {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Hauptentität: Media ----------------------------------------------
|
||||||
|
|
||||||
|
# Status-Werte (an MAL angelehnt, aber für Bücher + Serien nutzbar):
|
||||||
|
# plan -> auf der Liste / will ich lesen/schauen
|
||||||
|
# reading -> lese ich gerade (Buch) ODER watching (Serie)
|
||||||
|
# done -> fertig (abgeschlossen)
|
||||||
|
# hold -> pausiert
|
||||||
|
# dropped -> abgebrochen
|
||||||
|
STATUS_VALUES = ("plan", "reading", "done", "hold", "dropped")
|
||||||
|
KIND_VALUES = ("book", "series")
|
||||||
|
|
||||||
|
|
||||||
|
class Media(Base):
|
||||||
|
"""Ein Watchlist-Eintrag – Buch ODER Serie."""
|
||||||
|
|
||||||
|
__tablename__ = "media"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
|
# Allgemein
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False) # book|series
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
original_title: Mapped[Optional[str]] = mapped_column(String(200))
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
cover_url: Mapped[Optional[str]] = mapped_column(String(600))
|
||||||
|
release_year: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="plan")
|
||||||
|
rating: Mapped[Optional[float]] = mapped_column(Float) # 1-10
|
||||||
|
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Buch-spezifisch
|
||||||
|
author: Mapped[Optional[str]] = mapped_column(String(200))
|
||||||
|
isbn: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
total_volumes: Mapped[Optional[int]] = mapped_column(Integer) # Bände
|
||||||
|
total_chapters: Mapped[Optional[int]] = mapped_column(Integer) # Kapitel
|
||||||
|
total_pages: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
volumes_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
||||||
|
chapters_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
||||||
|
pages_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
# Serien-spezifisch
|
||||||
|
network: Mapped[Optional[str]] = mapped_column(String(120)) # Sender/Streaming
|
||||||
|
total_seasons: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
total_episodes: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
episodes_watched: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
||||||
|
season_watching: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
start_date: Mapped[Optional[date]] = mapped_column(Date)
|
||||||
|
end_date: Mapped[Optional[date]] = mapped_column(Date)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=_utcnow, onupdate=_utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
genres: Mapped[list[Genre]] = relationship(Genre, secondary=media_genres, lazy="selectin")
|
||||||
|
tags: Mapped[list[Tag]] = relationship(Tag, secondary=media_tags, lazy="selectin")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_media_kind_status", "kind", "status"),
|
||||||
|
Index("ix_media_title", "title"),
|
||||||
|
CheckConstraint("kind IN ('book','series')", name="ck_media_kind"),
|
||||||
|
CheckConstraint("status IN ('plan','reading','done','hold','dropped')", name="ck_media_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress_percent(self) -> float:
|
||||||
|
"""Fortschritt in Prozent, je nach Medium."""
|
||||||
|
if self.kind == "book":
|
||||||
|
total = self.total_chapters or self.total_pages or self.total_volumes or 0
|
||||||
|
done = self.chapters_read or self.pages_read or self.volumes_read or 0
|
||||||
|
else: # series
|
||||||
|
total = self.total_episodes or 0
|
||||||
|
done = self.episodes_watched or 0
|
||||||
|
if total <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round(min(100.0, done * 100.0 / total), 1)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Media {self.kind}:{self.title!r}>"
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
"""Pydantic-Schemas für API-Ein- und -Ausgabe."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Eingaben ---------------------------------------------------------
|
||||||
|
|
||||||
|
MediaKind = Literal["book", "series"]
|
||||||
|
MediaStatus = Literal["plan", "reading", "done", "hold", "dropped"]
|
||||||
|
|
||||||
|
|
||||||
|
class MediaBase(BaseModel):
|
||||||
|
kind: MediaKind
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
original_title: Optional[str] = Field(None, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
cover_url: Optional[str] = Field(None, max_length=600)
|
||||||
|
release_year: Optional[int] = Field(None, ge=1800, le=2200)
|
||||||
|
status: MediaStatus = "plan"
|
||||||
|
rating: Optional[float] = Field(None, ge=0, le=10)
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
# Buch-Felder
|
||||||
|
author: Optional[str] = Field(None, max_length=200)
|
||||||
|
isbn: Optional[str] = Field(None, max_length=20)
|
||||||
|
total_volumes: Optional[int] = Field(None, ge=0)
|
||||||
|
total_chapters: Optional[int] = Field(None, ge=0)
|
||||||
|
total_pages: Optional[int] = Field(None, ge=0)
|
||||||
|
volumes_read: Optional[int] = Field(None, ge=0)
|
||||||
|
chapters_read: Optional[int] = Field(None, ge=0)
|
||||||
|
pages_read: Optional[int] = Field(None, ge=0)
|
||||||
|
|
||||||
|
# Serien-Felder
|
||||||
|
network: Optional[str] = Field(None, max_length=120)
|
||||||
|
total_seasons: Optional[int] = Field(None, ge=0)
|
||||||
|
total_episodes: Optional[int] = Field(None, ge=0)
|
||||||
|
episodes_watched: Optional[int] = Field(None, ge=0)
|
||||||
|
season_watching: Optional[int] = Field(None, ge=0)
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
|
||||||
|
genres: List[str] = []
|
||||||
|
tags: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MediaCreate(MediaBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUpdate(BaseModel):
|
||||||
|
"""Alle Felder optional – PATCH-Semantik."""
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
original_title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
cover_url: Optional[str] = None
|
||||||
|
release_year: Optional[int] = None
|
||||||
|
status: Optional[MediaStatus] = None
|
||||||
|
rating: Optional[float] = Field(None, ge=0, le=10)
|
||||||
|
notes: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
isbn: Optional[str] = None
|
||||||
|
total_volumes: Optional[int] = None
|
||||||
|
total_chapters: Optional[int] = None
|
||||||
|
total_pages: Optional[int] = None
|
||||||
|
volumes_read: Optional[int] = None
|
||||||
|
chapters_read: Optional[int] = None
|
||||||
|
pages_read: Optional[int] = None
|
||||||
|
network: Optional[str] = None
|
||||||
|
total_seasons: Optional[int] = None
|
||||||
|
total_episodes: Optional[int] = None
|
||||||
|
episodes_watched: Optional[int] = None
|
||||||
|
season_watching: Optional[int] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
genres: Optional[List[str]] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressUpdate(BaseModel):
|
||||||
|
"""Schnelles Fortschritts-Update."""
|
||||||
|
delta: int = 1
|
||||||
|
set_to: Optional[int] = None # wenn angegeben, setzt absolut
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Ausgaben ---------------------------------------------------------
|
||||||
|
|
||||||
|
class GenreOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class MediaOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
kind: str
|
||||||
|
title: str
|
||||||
|
original_title: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
cover_url: Optional[str]
|
||||||
|
release_year: Optional[int]
|
||||||
|
status: str
|
||||||
|
rating: Optional[float]
|
||||||
|
notes: Optional[str]
|
||||||
|
author: Optional[str]
|
||||||
|
isbn: Optional[str]
|
||||||
|
total_volumes: Optional[int]
|
||||||
|
total_chapters: Optional[int]
|
||||||
|
total_pages: Optional[int]
|
||||||
|
volumes_read: Optional[int]
|
||||||
|
chapters_read: Optional[int]
|
||||||
|
pages_read: Optional[int]
|
||||||
|
network: Optional[str]
|
||||||
|
total_seasons: Optional[int]
|
||||||
|
total_episodes: Optional[int]
|
||||||
|
episodes_watched: Optional[int]
|
||||||
|
season_watching: Optional[int]
|
||||||
|
start_date: Optional[date]
|
||||||
|
end_date: Optional[date]
|
||||||
|
progress_percent: float
|
||||||
|
genres: List[GenreOut]
|
||||||
|
tags: List[TagOut]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StatsBucket(BaseModel):
|
||||||
|
status: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class StatsPerKind(BaseModel):
|
||||||
|
kind: str
|
||||||
|
total: int
|
||||||
|
avg_rating: Optional[float]
|
||||||
|
by_status: List[StatsBucket]
|
||||||
|
|
||||||
|
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
books: StatsPerKind
|
||||||
|
series: StatsPerKind
|
||||||
|
total_entries: int
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
"""Legt ein paar Demo-Datensätze an, falls die DB leer ist."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import crud, schemas
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEMO_BOOKS = [
|
||||||
|
{
|
||||||
|
"title": "Der Name des Windes",
|
||||||
|
"author": "Patrick Rothfuss",
|
||||||
|
"release_year": 2007,
|
||||||
|
"status": "done",
|
||||||
|
"rating": 9.0,
|
||||||
|
"total_pages": 662,
|
||||||
|
"pages_read": 662,
|
||||||
|
"genres": ["Fantasy", "Roman"],
|
||||||
|
"tags": ["Königsmörder-Chroniken"],
|
||||||
|
"description": "Kvothe erzählt sein Leben – von der Kindheit bis zur Universität.",
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/isbn/9783608938284-L.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Project Hail Mary",
|
||||||
|
"author": "Andy Weir",
|
||||||
|
"release_year": 2021,
|
||||||
|
"status": "reading",
|
||||||
|
"rating": 8.5,
|
||||||
|
"total_pages": 476,
|
||||||
|
"pages_read": 120,
|
||||||
|
"genres": ["Science-Fiction", "Roman"],
|
||||||
|
"tags": ["Hard SF", "All-Age"],
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/isbn/9780593135204-L.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Atomic Habits",
|
||||||
|
"author": "James Clear",
|
||||||
|
"release_year": 2018,
|
||||||
|
"status": "plan",
|
||||||
|
"total_pages": 320,
|
||||||
|
"pages_read": 0,
|
||||||
|
"genres": ["Sachbuch", "Selbsthilfe"],
|
||||||
|
"tags": ["Produktivität"],
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/isbn/9780735211292-L.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Die Foundation",
|
||||||
|
"author": "Isaac Asimov",
|
||||||
|
"release_year": 1951,
|
||||||
|
"status": "dropped",
|
||||||
|
"rating": 6.0,
|
||||||
|
"total_pages": 244,
|
||||||
|
"pages_read": 80,
|
||||||
|
"genres": ["Science-Fiction", "Klassiker"],
|
||||||
|
"tags": ["Space Opera"],
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/isbn/9780553293357-L.jpg",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEMO_SERIES = [
|
||||||
|
{
|
||||||
|
"title": "Breaking Bad",
|
||||||
|
"release_year": 2008,
|
||||||
|
"status": "done",
|
||||||
|
"rating": 9.5,
|
||||||
|
"total_seasons": 5,
|
||||||
|
"total_episodes": 62,
|
||||||
|
"episodes_watched": 62,
|
||||||
|
"network": "AMC",
|
||||||
|
"genres": ["Drama", "Thriller", "Crime"],
|
||||||
|
"tags": ["Anti-Held", "Must-Watch"],
|
||||||
|
"description": "Ein Chemielehrer steigt nach Krebsdiagnose in die Meth-Produktion ein.",
|
||||||
|
"cover_url": "https://m.media-amazon.com/images/M/MV5BYmQ4YWM2YWMtODNkNy00ZDNkLThiMDItZWE2MjAyMDUwYzhjL2ltYWdlL2ltYWdlXkEyXkFqcGdeQXVyMTMzNDExODE5._V1_.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Severance",
|
||||||
|
"release_year": 2022,
|
||||||
|
"status": "reading",
|
||||||
|
"rating": 8.7,
|
||||||
|
"total_seasons": 2,
|
||||||
|
"total_episodes": 19,
|
||||||
|
"episodes_watched": 9,
|
||||||
|
"season_watching": 1,
|
||||||
|
"network": "Apple TV+",
|
||||||
|
"genres": ["Sci-Fi", "Thriller", "Drama"],
|
||||||
|
"tags": ["Workplace", "Mind-Bender"],
|
||||||
|
"cover_url": "https://m.media-amazon.com/images/M/MV5BZjI0M2NlOTQtZmEzMy00MzMwLWEzYTktNmFhN2Q4NjIzNjRiXkEyXkFqcGdeQXVyMTEyMjM2NDc2._V1_.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "One Piece",
|
||||||
|
"release_year": 1999,
|
||||||
|
"status": "plan",
|
||||||
|
"total_seasons": 21,
|
||||||
|
"total_episodes": 1100,
|
||||||
|
"episodes_watched": 0,
|
||||||
|
"network": "Toei Animation",
|
||||||
|
"genres": ["Anime", "Abenteuer", "Action"],
|
||||||
|
"tags": ["Long Runner"],
|
||||||
|
"cover_url": "https://m.media-amazon.com/images/M/MV5BODcwNWE3ZmMtYjIyZi00NzUxLTgwM2ItZGIwNTZjMjllNDdmXkEyXkFqcGdeQXVyNTAyODkwOQ@@._V1_.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Dark",
|
||||||
|
"release_year": 2017,
|
||||||
|
"status": "hold",
|
||||||
|
"rating": 8.0,
|
||||||
|
"total_seasons": 3,
|
||||||
|
"total_episodes": 26,
|
||||||
|
"episodes_watched": 10,
|
||||||
|
"season_watching": 2,
|
||||||
|
"network": "Netflix",
|
||||||
|
"genres": ["Sci-Fi", "Mystery", "Thriller"],
|
||||||
|
"tags": ["Time-Travel", "DE"],
|
||||||
|
"cover_url": "https://m.media-amazon.com/images/M/MV5BYTRkNGE2MjItMjVkZi00MzVlLWE3NjgtMjI4N2QxY2Y3YjRiXkEyXkFqcGdeQXVyMTAzMDg4NzU0._V1_.jpg",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_if_empty(db: Session) -> None:
|
||||||
|
if db.query(__import__("app").models.Media).count() > 0: # type: ignore[attr-defined]
|
||||||
|
return
|
||||||
|
log.info("Leere DB – lege Demo-Daten an.")
|
||||||
|
for spec in DEMO_BOOKS:
|
||||||
|
crud.create_media(db, schemas.MediaCreate(kind="book", **spec))
|
||||||
|
for spec in DEMO_SERIES:
|
||||||
|
crud.create_media(db, schemas.MediaCreate(kind="series", **spec))
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
/* ========================================================================
|
||||||
|
WatchStack — Theme
|
||||||
|
- Dark default (wie MAL's moderner Look)
|
||||||
|
- Eine Akzentfarbe (cyan-violett), warme Status-Farben
|
||||||
|
======================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1216;
|
||||||
|
--bg-elev: #161b22;
|
||||||
|
--bg-elev-2: #1d242d;
|
||||||
|
--border: #2a313c;
|
||||||
|
--border-strong: #3a424f;
|
||||||
|
--text: #e8ecf2;
|
||||||
|
--text-dim: #9aa3b1;
|
||||||
|
--text-muted: #6e7684;
|
||||||
|
--accent: #7c5cff;
|
||||||
|
--accent-2: #22d3ee;
|
||||||
|
--accent-grad: linear-gradient(135deg, #7c5cff 0%, #22d3ee 100%);
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--success: #22c55e;
|
||||||
|
--plan: #94a3b8;
|
||||||
|
--reading: #38bdf8;
|
||||||
|
--done: #22c55e;
|
||||||
|
--hold: #f59e0b;
|
||||||
|
--dropped: #ef4444;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
|
||||||
|
--shadow-md: 0 8px 24px rgba(0,0,0,.35);
|
||||||
|
--transition: 180ms cubic-bezier(.2,.7,.2,1);
|
||||||
|
--container: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; cursor: pointer; }
|
||||||
|
input, select, textarea, button { font: inherit; }
|
||||||
|
a { color: var(--accent-2); text-decoration: none; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ===================== Hero ===================== */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(180deg, #1a1f2b 0%, #0f1216 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero-bg {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(800px 400px at 20% 0%, rgba(124,92,255,.18), transparent 60%),
|
||||||
|
radial-gradient(700px 350px at 80% 0%, rgba(34,211,238,.15), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hero-inner {
|
||||||
|
position: relative;
|
||||||
|
max-width: var(--container);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--accent-grad);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-weight: 800; font-size: 26px;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 8px 20px rgba(124,92,255,.35);
|
||||||
|
}
|
||||||
|
.brand-text h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
background: var(--accent-grad);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.brand-text p { margin: 2px 0 0; color: var(--text-dim); font-size: 14px; }
|
||||||
|
|
||||||
|
.hero-search {
|
||||||
|
display: flex; gap: 10px; flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hero-search input[type="search"] {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.hero-search input[type="search"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(124,92,255,.18);
|
||||||
|
}
|
||||||
|
.hero-search select {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform var(--transition), background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.btn:hover { background: #252d39; transform: translateY(-1px); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-grad);
|
||||||
|
border-color: transparent;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 6px 16px rgba(124,92,255,.35);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { filter: brightness(1.08); background: var(--accent-grad); }
|
||||||
|
.btn-ghost { background: transparent; }
|
||||||
|
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||||
|
.btn-danger { background: rgba(239,68,68,.15); border-color: rgba(239,68,68,.4); color: #fda4a4; }
|
||||||
|
.btn-danger:hover { background: rgba(239,68,68,.25); }
|
||||||
|
|
||||||
|
/* Status-Tabs */
|
||||||
|
.status-tabs {
|
||||||
|
position: relative;
|
||||||
|
max-width: var(--container);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4px 18px 0;
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex; gap: 6px; align-items: center;
|
||||||
|
transition: color var(--transition), border-color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: rgba(124,92,255,.06);
|
||||||
|
}
|
||||||
|
.tab .count {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Container ===================== */
|
||||||
|
.container {
|
||||||
|
max-width: var(--container);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips (Genres/Tags) */
|
||||||
|
.chips {
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.chip-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--text); border-color: var(--border-strong); }
|
||||||
|
.chip.active {
|
||||||
|
background: var(--accent-grad);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Grid / Cards ===================== */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
background: linear-gradient(135deg, #1f2630, #2a323e);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform var(--transition);
|
||||||
|
}
|
||||||
|
.card:hover .cover img { transform: scale(1.04); }
|
||||||
|
.cover-fallback {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 32px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 2.5em;
|
||||||
|
}
|
||||||
|
.card-sub {
|
||||||
|
font-size: 12px; color: var(--text-muted);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.status-plan { background: rgba(148,163,184,.15); color: var(--plan); }
|
||||||
|
.status-reading { background: rgba(56,189,248,.15); color: var(--reading); }
|
||||||
|
.status-done { background: rgba(34,197,94,.15); color: var(--done); }
|
||||||
|
.status-hold { background: rgba(245,158,11,.15); color: var(--hold); }
|
||||||
|
.status-dropped { background: rgba(239,68,68,.15); color: var(--dropped); }
|
||||||
|
.rating { font-size: 12px; color: #facc15; font-weight: 700; }
|
||||||
|
|
||||||
|
.kind-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px; left: 8px;
|
||||||
|
background: rgba(0,0,0,.65);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar > div {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-grad);
|
||||||
|
transition: width var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.empty h2 { color: var(--text); margin-bottom: 6px; }
|
||||||
|
|
||||||
|
/* ===================== Modal ===================== */
|
||||||
|
.modal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; }
|
||||||
|
.modal-backdrop {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0,0,0,.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: min(680px, 92vw);
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; background: var(--bg-elev); z-index: 1;
|
||||||
|
}
|
||||||
|
.modal-header h2 { margin: 0; font-size: 18px; }
|
||||||
|
.icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--bg-elev-2); color: var(--text); }
|
||||||
|
|
||||||
|
.form { padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
|
||||||
|
.field.grow { flex: 1 1 220px; }
|
||||||
|
.field span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(124,92,255,.18);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 14px 20px;
|
||||||
|
position: sticky; bottom: 0;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conditional field rows by media kind */
|
||||||
|
.row[data-when] { display: none; }
|
||||||
|
body.kind-book .row[data-when="book"] { display: flex; }
|
||||||
|
body.kind-series .row[data-when="series"] { display: flex; }
|
||||||
|
|
||||||
|
/* ===================== Drawer (Detail) ===================== */
|
||||||
|
.drawer { position: fixed; inset: 0; z-index: 60; }
|
||||||
|
.drawer-backdrop {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0,0,0,.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.drawer-card {
|
||||||
|
position: absolute; right: 0; top: 0; bottom: 0;
|
||||||
|
width: min(680px, 95vw);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: slide-in .25s ease;
|
||||||
|
}
|
||||||
|
@keyframes slide-in {
|
||||||
|
from { transform: translateX(40px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.drawer-close {
|
||||||
|
position: absolute; top: 14px; right: 14px; z-index: 2;
|
||||||
|
background: rgba(0,0,0,.5);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.detail-hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 28px 24px 20px;
|
||||||
|
background: linear-gradient(135deg, rgba(124,92,255,.2), rgba(34,211,238,.15));
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.detail-hero .row-top { display: flex; gap: 18px; align-items: flex-start; }
|
||||||
|
.detail-hero .cover-mini {
|
||||||
|
width: 110px; aspect-ratio: 2/3;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.detail-hero .cover-mini img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.detail-hero .meta-title h2 {
|
||||||
|
margin: 0 0 4px; font-size: 24px; line-height: 1.15;
|
||||||
|
}
|
||||||
|
.detail-hero .meta-title .sub { color: var(--text-dim); font-size: 14px; }
|
||||||
|
.detail-hero .badges { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
.detail-section { padding: 18px 24px; border-bottom: 1px solid var(--border); }
|
||||||
|
.detail-section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px,1fr)); gap: 12px; }
|
||||||
|
.stat-grid .stat {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.stat .label { font-size: 11px; text-transform: uppercase; color: var(--text-muted); letter-spacing: .06em; }
|
||||||
|
.stat .value { font-size: 18px; font-weight: 700; margin-top: 2px; }
|
||||||
|
|
||||||
|
.progress-controls {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.progress-controls input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap;
|
||||||
|
padding: 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags in Detail */
|
||||||
|
.tag-list { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.tag-list .chip { cursor: default; }
|
||||||
|
|
||||||
|
/* Rating slider */
|
||||||
|
.rating-display {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
.rating-display .star { font-size: 18px; }
|
||||||
|
|
||||||
|
/* ===================== Stats modal ===================== */
|
||||||
|
.stats-card { width: min(560px, 92vw); }
|
||||||
|
.stat-block { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.stat-block:last-child { border-bottom: none; }
|
||||||
|
.stat-block h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; color: var(--text-muted); letter-spacing: .08em; }
|
||||||
|
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||||
|
.bar-row .label { width: 110px; color: var(--text-dim); font-size: 13px; }
|
||||||
|
.bar-row .bar {
|
||||||
|
flex: 1; height: 14px; background: var(--bg); border-radius: 8px; overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bar-row .bar > div {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-grad);
|
||||||
|
transition: width var(--transition);
|
||||||
|
}
|
||||||
|
.bar-row .count { font-weight: 700; min-width: 30px; text-align: right; }
|
||||||
|
.big-number {
|
||||||
|
font-size: 32px; font-weight: 800;
|
||||||
|
background: var(--accent-grad);
|
||||||
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Toast ===================== */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 100;
|
||||||
|
animation: toast-in .25s ease;
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translate(-50%, 10px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Responsive ===================== */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero-inner { padding: 18px 16px 10px; }
|
||||||
|
.container { padding: 14px 16px 60px; }
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||||||
|
.brand-text h1 { font-size: 22px; }
|
||||||
|
.status-tabs { padding: 4px 12px 0; }
|
||||||
|
.tab { padding: 10px 10px; font-size: 13px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#7c5cff"/>
|
||||||
|
<stop offset="100%" stop-color="#22d3ee"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||||
|
<text x="32" y="44" font-family="Arial, sans-serif" font-size="38" font-weight="800" fill="white" text-anchor="middle">W</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 447 B |
@@ -0,0 +1,227 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WatchStack – Watchlist für Bücher & Serien</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ===================== Header / Hero ===================== -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-bg"></div>
|
||||||
|
<div class="hero-inner">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">W</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>WatchStack</h1>
|
||||||
|
<p>Deine Watchlist für <strong>Bücher</strong>, <strong>Serien</strong> & mehr.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-search">
|
||||||
|
<input id="search-input" type="search" placeholder="Titel, Autor oder Genre suchen…" autocomplete="off">
|
||||||
|
<select id="filter-kind">
|
||||||
|
<option value="">Alle Medien</option>
|
||||||
|
<option value="book">Nur Bücher</option>
|
||||||
|
<option value="series">Nur Serien</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
<option value="plan">Geplant</option>
|
||||||
|
<option value="reading">Laufend</option>
|
||||||
|
<option value="done">Abgeschlossen</option>
|
||||||
|
<option value="hold">Pausiert</option>
|
||||||
|
<option value="dropped">Abgebrochen</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-sort">
|
||||||
|
<option value="updated_desc">Zuletzt geändert</option>
|
||||||
|
<option value="title_asc">Titel A–Z</option>
|
||||||
|
<option value="title_desc">Titel Z–A</option>
|
||||||
|
<option value="rating_desc">Beste Bewertung</option>
|
||||||
|
<option value="rating_asc">Schlechteste Bewertung</option>
|
||||||
|
<option value="year_desc">Neueste zuerst</option>
|
||||||
|
<option value="year_asc">Älteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
<button id="open-stats" class="btn btn-ghost" title="Statistik anzeigen">📊</button>
|
||||||
|
<button id="open-add" class="btn btn-primary">+ Neu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status-Tabs (MAL-Style) -->
|
||||||
|
<nav class="status-tabs" id="status-tabs">
|
||||||
|
<button class="tab active" data-status="">Alle</button>
|
||||||
|
<button class="tab" data-status="plan">Geplant <span class="count" id="cnt-plan">0</span></button>
|
||||||
|
<button class="tab" data-status="reading">Laufend <span class="count" id="cnt-reading">0</span></button>
|
||||||
|
<button class="tab" data-status="done">Abgeschlossen <span class="count" id="cnt-done">0</span></button>
|
||||||
|
<button class="tab" data-status="hold">Pausiert <span class="count" id="cnt-hold">0</span></button>
|
||||||
|
<button class="tab" data-status="dropped">Abgebrochen <span class="count" id="cnt-dropped">0</span></button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===================== Main ===================== -->
|
||||||
|
<main class="container">
|
||||||
|
<!-- Genre/Tag-Chips (Filter) -->
|
||||||
|
<div class="chips" id="genre-chips" aria-label="Genres">
|
||||||
|
<span class="chip-label">Genres:</span>
|
||||||
|
<!-- per JS befüllt -->
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="tag-chips" aria-label="Tags">
|
||||||
|
<span class="chip-label">Tags:</span>
|
||||||
|
<!-- per JS befüllt -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Listen-Grid -->
|
||||||
|
<section id="grid" class="grid">
|
||||||
|
<!-- Cards per JS -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="empty-state" class="empty hidden">
|
||||||
|
<h2>Noch nichts hier.</h2>
|
||||||
|
<p>Klick auf <strong>+ Neu</strong> und leg deinen ersten Eintrag an.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===================== Modal: Add / Edit ===================== -->
|
||||||
|
<div class="modal" id="modal-add" hidden>
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="modal-title">Neuer Eintrag</h2>
|
||||||
|
<button class="icon-btn" data-close aria-label="Schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
<form id="form-add" class="form">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Art</span>
|
||||||
|
<select name="kind" required>
|
||||||
|
<option value="book">📚 Buch</option>
|
||||||
|
<option value="series">📺 Serie</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select name="status">
|
||||||
|
<option value="plan">Geplant</option>
|
||||||
|
<option value="reading">Laufend</option>
|
||||||
|
<option value="done">Abgeschlossen</option>
|
||||||
|
<option value="hold">Pausiert</option>
|
||||||
|
<option value="dropped">Abgebrochen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Titel*</span>
|
||||||
|
<input name="title" required maxlength="200">
|
||||||
|
<button type="button" id="btn-lookup" class="btn btn-ghost small" title="Online nachschlagen">🔍 Nachschlagen</button>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Jahr</span>
|
||||||
|
<input name="release_year" type="number" min="1800" max="2200">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buch-Felder -->
|
||||||
|
<div class="row" data-when="book">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Autor</span>
|
||||||
|
<input name="author" maxlength="200">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>ISBN</span>
|
||||||
|
<input name="isbn" maxlength="20" placeholder="z.B. 9783608938284">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row" data-when="book">
|
||||||
|
<label class="field"><span>Bände</span><input name="total_volumes" type="number" min="0"></label>
|
||||||
|
<label class="field"><span>Kapitel</span><input name="total_chapters" type="number" min="0"></label>
|
||||||
|
<label class="field"><span>Seiten</span><input name="total_pages" type="number" min="0"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Serien-Felder -->
|
||||||
|
<div class="row" data-when="series">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Sender / Streaming</span>
|
||||||
|
<input name="network" maxlength="120" placeholder="z.B. Netflix, ARD, …">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Staffeln</span><input name="total_seasons" type="number" min="0"></label>
|
||||||
|
<label class="field"><span>Episoden</span><input name="total_episodes" type="number" min="0"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row" data-when="series">
|
||||||
|
<label class="field"><span>Start</span><input name="start_date" type="date"></label>
|
||||||
|
<label class="field"><span>Ende</span><input name="end_date" type="date"></label>
|
||||||
|
<label class="field"><span>Staffel aktuell</span><input name="season_watching" type="number" min="0"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Cover-URL</span>
|
||||||
|
<input name="cover_url" placeholder="https://… (oder per Nachschlagen automatisch)">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Bewertung (0–10)</span>
|
||||||
|
<input name="rating" type="number" step="0.1" min="0" max="10">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Beschreibung</span>
|
||||||
|
<textarea name="description" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Genres (Komma-getrennt)</span>
|
||||||
|
<input name="genres" placeholder="Fantasy, Sci-Fi, Roman">
|
||||||
|
</label>
|
||||||
|
<label class="field grow">
|
||||||
|
<span>Tags (Komma-getrennt)</span>
|
||||||
|
<input name="tags" placeholder="long-runner, klassiker">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Notizen</span>
|
||||||
|
<textarea name="notes" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<footer class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-ghost" data-close>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Drawer: Detail ===================== -->
|
||||||
|
<aside class="drawer" id="drawer" hidden>
|
||||||
|
<div class="drawer-backdrop" data-close-drawer></div>
|
||||||
|
<div class="drawer-card">
|
||||||
|
<button class="icon-btn drawer-close" data-close-drawer aria-label="Schließen">✕</button>
|
||||||
|
<div id="drawer-content"><!-- per JS --></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ===================== Modal: Stats ===================== -->
|
||||||
|
<div class="modal" id="modal-stats" hidden>
|
||||||
|
<div class="modal-backdrop" data-close-stats></div>
|
||||||
|
<div class="modal-card stats-card">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2>Deine Statistik</h2>
|
||||||
|
<button class="icon-btn" data-close-stats aria-label="Schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
<div id="stats-body"><!-- per JS --></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Toast ===================== -->
|
||||||
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/* ========================================================================
|
||||||
|
WatchStack – Client App
|
||||||
|
======================================================================== */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
items: [],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
filter: {
|
||||||
|
kind: '',
|
||||||
|
status: '',
|
||||||
|
search: '',
|
||||||
|
genre: '',
|
||||||
|
tag: '',
|
||||||
|
sort: 'updated_desc',
|
||||||
|
},
|
||||||
|
editing: null, // media id when editing, null when creating
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async get(url) {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
async send(method, url, body) {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok && r.status !== 204) {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || r.statusText);
|
||||||
|
}
|
||||||
|
return r.status === 204 ? null : r.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function toast(msg, kind = 'info') {
|
||||||
|
const el = $('#toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.hidden = false;
|
||||||
|
el.style.borderColor =
|
||||||
|
kind === 'error' ? 'rgba(239,68,68,.5)' :
|
||||||
|
kind === 'success' ? 'rgba(34,197,94,.5)' :
|
||||||
|
'var(--border-strong)';
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => { el.hidden = true; }, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stars(rating) {
|
||||||
|
if (rating == null) return '';
|
||||||
|
return `<span class="rating-display" title="${rating}/10">★ ${Number(rating).toFixed(1)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
return ({
|
||||||
|
plan: 'Geplant', reading: 'Laufend', done: 'Abgeschlossen',
|
||||||
|
hold: 'Pausiert', dropped: 'Abgebrochen',
|
||||||
|
})[s] || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(k) {
|
||||||
|
return k === 'book' ? '📚 Buch' : '📺 Serie';
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindIcon(k) {
|
||||||
|
return k === 'book' ? '📚' : '📺';
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressText(m) {
|
||||||
|
if (m.kind === 'book') {
|
||||||
|
const total = m.total_chapters || m.total_pages || m.total_volumes || 0;
|
||||||
|
const done = m.chapters_read || m.pages_read || m.volumes_read || 0;
|
||||||
|
const unit = m.total_chapters ? 'Kap.' : m.total_pages ? 'S.' : 'Bd.';
|
||||||
|
return total ? `${done} / ${total} ${unit}` : '—';
|
||||||
|
}
|
||||||
|
const total = m.total_episodes || 0;
|
||||||
|
const done = m.episodes_watched || 0;
|
||||||
|
return total ? `${done} / ${total} Ep.` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Initial Load ----------------------------------------------------
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (state.filter.kind) params.set('kind', state.filter.kind);
|
||||||
|
if (state.filter.status) params.set('status', state.filter.status);
|
||||||
|
if (state.filter.search) params.set('search', state.filter.search);
|
||||||
|
if (state.filter.genre) params.set('genre', state.filter.genre);
|
||||||
|
if (state.filter.tag) params.set('tag', state.filter.tag);
|
||||||
|
params.set('sort', state.filter.sort);
|
||||||
|
|
||||||
|
const [items, genres, tags] = await Promise.all([
|
||||||
|
api.get(`/api/media?${params}`),
|
||||||
|
api.get('/api/genres'),
|
||||||
|
api.get('/api/tags'),
|
||||||
|
]);
|
||||||
|
state.items = items;
|
||||||
|
state.genres = genres;
|
||||||
|
state.tags = tags;
|
||||||
|
renderAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Fehler beim Laden: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Render ----------------------------------------------------------
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderChips();
|
||||||
|
renderCounts();
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChips() {
|
||||||
|
const g = $('#genre-chips');
|
||||||
|
const t = $('#tag-chips');
|
||||||
|
g.innerHTML = '<span class="chip-label">Genres:</span>';
|
||||||
|
t.innerHTML = '<span class="chip-label">Tags:</span>';
|
||||||
|
state.genres.forEach(x => {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.className = 'chip' + (state.filter.genre === x.name ? ' active' : '');
|
||||||
|
el.textContent = x.name;
|
||||||
|
el.onclick = () => { state.filter.genre = state.filter.genre === x.name ? '' : x.name; loadAll(); };
|
||||||
|
g.appendChild(el);
|
||||||
|
});
|
||||||
|
state.tags.forEach(x => {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.className = 'chip' + (state.filter.tag === x.name ? ' active' : '');
|
||||||
|
el.textContent = x.name;
|
||||||
|
el.onclick = () => { state.filter.tag = state.filter.tag === x.name ? '' : x.name; loadAll(); };
|
||||||
|
t.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCounts() {
|
||||||
|
const counts = { plan: 0, reading: 0, done: 0, hold: 0, dropped: 0 };
|
||||||
|
let filtered = state.items;
|
||||||
|
if (state.filter.kind) filtered = filtered.filter(m => m.kind === state.filter.kind);
|
||||||
|
if (state.filter.search) {
|
||||||
|
const s = state.filter.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(m =>
|
||||||
|
(m.title || '').toLowerCase().includes(s) ||
|
||||||
|
(m.author || '').toLowerCase().includes(s) ||
|
||||||
|
(m.original_title || '').toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
filtered.forEach(m => { counts[m.status] = (counts[m.status] || 0) + 1; });
|
||||||
|
Object.entries(counts).forEach(([k, v]) => {
|
||||||
|
const el = $('#cnt-' + k);
|
||||||
|
if (el) el.textContent = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = $('#grid');
|
||||||
|
const empty = $('#empty-state');
|
||||||
|
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
|
||||||
|
grid.innerHTML = state.items.map(m => `
|
||||||
|
<article class="card" data-id="${m.id}">
|
||||||
|
<div class="cover">
|
||||||
|
<span class="kind-badge">${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'}</span>
|
||||||
|
${m.cover_url
|
||||||
|
? `<img src="${escapeHtml(m.cover_url)}" alt="${escapeHtml(m.title)}" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
|
||||||
|
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 28))}</div>`}
|
||||||
|
<div class="progress-bar"><div style="width:${m.progress_percent}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">${escapeHtml(m.title)}</div>
|
||||||
|
<div class="card-sub">${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
|
||||||
|
${stars(m.rating)}
|
||||||
|
</div>
|
||||||
|
<div class="card-sub">${progressText(m)}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
$$('.card', grid).forEach(el => {
|
||||||
|
el.addEventListener('click', () => openDetail(parseInt(el.dataset.id, 10)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Filter / Suche --------------------------------------------------
|
||||||
|
|
||||||
|
function wireFilters() {
|
||||||
|
const search = $('#search-input');
|
||||||
|
let debounce;
|
||||||
|
search.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { state.filter.search = search.value.trim(); loadAll(); }, 220);
|
||||||
|
});
|
||||||
|
$('#filter-kind').addEventListener('change', e => { state.filter.kind = e.target.value; loadAll(); });
|
||||||
|
$('#filter-status').addEventListener('change', e => { state.filter.status = e.target.value; loadAll(); });
|
||||||
|
$('#filter-sort').addEventListener('change', e => { state.filter.sort = e.target.value; loadAll(); });
|
||||||
|
|
||||||
|
$$('#status-tabs .tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
$$('#status-tabs .tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
state.filter.status = tab.dataset.status;
|
||||||
|
$('#filter-status').value = state.filter.status;
|
||||||
|
loadAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modal: Add / Edit ---------------------------------------------
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
state.editing = null;
|
||||||
|
$('#modal-title').textContent = 'Neuer Eintrag';
|
||||||
|
const form = $('#form-add');
|
||||||
|
form.reset();
|
||||||
|
form.id.value = '';
|
||||||
|
form.kind.value = 'book';
|
||||||
|
document.body.classList.remove('kind-series');
|
||||||
|
document.body.classList.add('kind-book');
|
||||||
|
$('#modal-add').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(m) {
|
||||||
|
state.editing = m.id;
|
||||||
|
$('#modal-title').textContent = 'Bearbeiten: ' + m.title;
|
||||||
|
const form = $('#form-add');
|
||||||
|
form.reset();
|
||||||
|
form.id.value = m.id;
|
||||||
|
form.kind.value = m.kind;
|
||||||
|
document.body.classList.toggle('kind-book', m.kind === 'book');
|
||||||
|
document.body.classList.toggle('kind-series', m.kind === 'series');
|
||||||
|
// Felder setzen
|
||||||
|
const setVal = (name, val) => { if (form[name]) form[name].value = val ?? ''; };
|
||||||
|
setVal('title', m.title);
|
||||||
|
setVal('status', m.status);
|
||||||
|
setVal('release_year', m.release_year);
|
||||||
|
setVal('author', m.author);
|
||||||
|
setVal('isbn', m.isbn);
|
||||||
|
setVal('total_volumes', m.total_volumes);
|
||||||
|
setVal('total_chapters', m.total_chapters);
|
||||||
|
setVal('total_pages', m.total_pages);
|
||||||
|
setVal('network', m.network);
|
||||||
|
setVal('total_seasons', m.total_seasons);
|
||||||
|
setVal('total_episodes', m.total_episodes);
|
||||||
|
setVal('season_watching', m.season_watching);
|
||||||
|
setVal('start_date', m.start_date);
|
||||||
|
setVal('end_date', m.end_date);
|
||||||
|
setVal('cover_url', m.cover_url);
|
||||||
|
setVal('rating', m.rating);
|
||||||
|
setVal('description', m.description);
|
||||||
|
setVal('notes', m.notes);
|
||||||
|
form.genres.value = (m.genres || []).map(g => g.name).join(', ');
|
||||||
|
form.tags.value = (m.tags || []).map(t => t.name).join(', ');
|
||||||
|
$('#modal-add').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireAddModal() {
|
||||||
|
const form = $('#form-add');
|
||||||
|
const modal = $('#modal-add');
|
||||||
|
|
||||||
|
$$('[data-close]', modal).forEach(b => b.addEventListener('click', () => { modal.hidden = true; }));
|
||||||
|
|
||||||
|
form.kind.addEventListener('change', () => {
|
||||||
|
document.body.classList.toggle('kind-book', form.kind.value === 'book');
|
||||||
|
document.body.classList.toggle('kind-series', form.kind.value === 'series');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Online-Lookup (Open Library per ISBN, sonst Titel)
|
||||||
|
$('#btn-lookup').addEventListener('click', async () => {
|
||||||
|
const isbn = form.isbn.value.trim();
|
||||||
|
const title = form.title.value.trim();
|
||||||
|
if (!isbn && !title) { toast('ISBN oder Titel eingeben', 'error'); return; }
|
||||||
|
try {
|
||||||
|
const params = isbn ? `?isbn=${encodeURIComponent(isbn)}` : `?title=${encodeURIComponent(title)}`;
|
||||||
|
const data = await api.get('/api/lookup/book' + params);
|
||||||
|
if (data.title && !form.title.value) form.title.value = data.title;
|
||||||
|
if (data.author && !form.author.value) form.author.value = data.author;
|
||||||
|
if (data.release_year && !form.release_year.value) form.release_year.value = data.release_year;
|
||||||
|
if (data.total_pages && !form.total_pages.value) form.total_pages.value = data.total_pages;
|
||||||
|
if (data.cover_url && !form.cover_url.value) form.cover_url.value = data.cover_url;
|
||||||
|
toast('Daten geladen ✓', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Lookup fehlgeschlagen: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const body = {};
|
||||||
|
for (const [k, v] of fd.entries()) {
|
||||||
|
if (k === 'id') continue;
|
||||||
|
body[k] = v;
|
||||||
|
}
|
||||||
|
// Numerische Felder
|
||||||
|
const intFields = ['release_year','total_volumes','total_chapters','total_pages',
|
||||||
|
'total_seasons','total_episodes','season_watching'];
|
||||||
|
intFields.forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseInt(body[f], 10); });
|
||||||
|
['rating'].forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseFloat(body[f]); });
|
||||||
|
|
||||||
|
body.genres = (body.genres || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
body.tags = (body.tags || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (state.editing) {
|
||||||
|
await api.send('PATCH', `/api/media/${state.editing}`, body);
|
||||||
|
toast('Aktualisiert ✓', 'success');
|
||||||
|
} else {
|
||||||
|
await api.send('POST', '/api/media', body);
|
||||||
|
toast('Hinzugefügt ✓', 'success');
|
||||||
|
}
|
||||||
|
modal.hidden = true;
|
||||||
|
await loadAll();
|
||||||
|
if (state.editing) openDetail(state.editing);
|
||||||
|
} catch (err) {
|
||||||
|
toast('Speichern fehlgeschlagen: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Drawer: Detail -------------------------------------------------
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
const m = await api.get('/api/media/' + id).catch(() => null);
|
||||||
|
if (!m) { toast('Eintrag nicht gefunden', 'error'); return; }
|
||||||
|
|
||||||
|
const c = $('#drawer-content');
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="detail-hero">
|
||||||
|
<div class="row-top">
|
||||||
|
<div class="cover-mini">
|
||||||
|
${m.cover_url
|
||||||
|
? `<img src="${escapeHtml(m.cover_url)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
|
||||||
|
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 20))}</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="meta-title">
|
||||||
|
<h2>${escapeHtml(m.title)}</h2>
|
||||||
|
<div class="sub">${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
|
||||||
|
${stars(m.rating)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${m.description ? `<section class="detail-section"><h3>Beschreibung</h3><p>${escapeHtml(m.description)}</p></section>` : ''}
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3>Status & Fortschritt</h3>
|
||||||
|
<div class="progress-bar" style="height:8px;border-radius:8px;overflow:hidden;background:var(--bg-elev-2);">
|
||||||
|
<div style="width:${m.progress_percent}%;height:100%;background:var(--accent-grad);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;color:var(--text-dim);font-size:13px;">${progressText(m)} (${m.progress_percent}%)</div>
|
||||||
|
<div class="progress-controls">
|
||||||
|
<button class="btn small" id="prog-minus">−1</button>
|
||||||
|
<button class="btn small" id="prog-plus">+1</button>
|
||||||
|
<input type="number" id="prog-set" min="0" placeholder="absolut">
|
||||||
|
<button class="btn small btn-primary" id="prog-save">Setzen</button>
|
||||||
|
</div>
|
||||||
|
<div class="progress-controls" style="margin-top:14px;">
|
||||||
|
${['plan','reading','done','hold','dropped'].map(s =>
|
||||||
|
`<button class="btn small status-btn" data-st="${s}">${statusLabel(s)}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3>Eckdaten</h3>
|
||||||
|
<div class="stat-grid">
|
||||||
|
${m.kind === 'book' ? `
|
||||||
|
<div class="stat"><div class="label">Autor</div><div class="value">${escapeHtml(m.author || '—')}</div></div>
|
||||||
|
<div class="stat"><div class="label">ISBN</div><div class="value" style="font-size:14px">${escapeHtml(m.isbn || '—')}</div></div>
|
||||||
|
<div class="stat"><div class="label">Bände</div><div class="value">${m.total_volumes ?? '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Kapitel</div><div class="value">${m.total_chapters ?? '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Seiten</div><div class="value">${m.total_pages ?? '—'}</div></div>
|
||||||
|
` : `
|
||||||
|
<div class="stat"><div class="label">Sender</div><div class="value" style="font-size:14px">${escapeHtml(m.network || '—')}</div></div>
|
||||||
|
<div class="stat"><div class="label">Staffeln</div><div class="value">${m.total_seasons ?? '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Episoden</div><div class="value">${m.total_episodes ?? '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Staffel aktuell</div><div class="value">${m.season_watching ?? '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Start</div><div class="value" style="font-size:14px">${m.start_date || '—'}</div></div>
|
||||||
|
<div class="stat"><div class="label">Ende</div><div class="value" style="font-size:14px">${m.end_date || '—'}</div></div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? `
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3>Genres & Tags</h3>
|
||||||
|
<div class="tag-list">
|
||||||
|
${(m.genres||[]).map(g => `<span class="chip">${escapeHtml(g.name)}</span>`).join('')}
|
||||||
|
${(m.tags||[]).map(t => `<span class="chip" style="border-style:dashed">#${escapeHtml(t.name)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${m.notes ? `<section class="detail-section"><h3>Notizen</h3><p style="white-space:pre-wrap">${escapeHtml(m.notes)}</p></section>` : ''}
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<button class="btn btn-primary" id="detail-edit">✎ Bearbeiten</button>
|
||||||
|
<button class="btn btn-danger" id="detail-delete">🗑 Löschen</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// wire drawer actions
|
||||||
|
$('#detail-edit').onclick = () => { closeDrawer(); openEdit(m); };
|
||||||
|
$('#detail-delete').onclick = async () => {
|
||||||
|
if (!confirm(`"${m.title}" wirklich löschen?`)) return;
|
||||||
|
try {
|
||||||
|
await api.send('DELETE', `/api/media/${m.id}`);
|
||||||
|
toast('Gelöscht', 'success');
|
||||||
|
closeDrawer();
|
||||||
|
loadAll();
|
||||||
|
} catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
};
|
||||||
|
$('#prog-plus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: 1 }); openDetail(m.id); loadAll(); };
|
||||||
|
$('#prog-minus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: -1 }); openDetail(m.id); loadAll(); };
|
||||||
|
$('#prog-save').onclick = async () => {
|
||||||
|
const v = parseInt($('#prog-set').value, 10);
|
||||||
|
if (Number.isNaN(v)) return;
|
||||||
|
await api.send('POST', `/api/media/${m.id}/progress`, { set_to: v });
|
||||||
|
openDetail(m.id); loadAll();
|
||||||
|
};
|
||||||
|
$$('.status-btn', c).forEach(btn => btn.onclick = async () => {
|
||||||
|
await api.send('PATCH', `/api/media/${m.id}`, { status: btn.dataset.st });
|
||||||
|
openDetail(m.id); loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#drawer').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() { $('#drawer').hidden = true; }
|
||||||
|
|
||||||
|
function wireDrawer() {
|
||||||
|
$$('[data-close-drawer]').forEach(b => b.addEventListener('click', closeDrawer));
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (!$('#modal-add').hidden) $('#modal-add').hidden = true;
|
||||||
|
else if (!$('#modal-stats').hidden) $('#modal-stats').hidden = true;
|
||||||
|
else if (!$('#drawer').hidden) closeDrawer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Stats -----------------------------------------------------------
|
||||||
|
|
||||||
|
async function openStats() {
|
||||||
|
try {
|
||||||
|
const s = await api.get('/api/stats');
|
||||||
|
const block = (kind, data, total) => {
|
||||||
|
const max = Math.max(1, ...data.map(b => b.count));
|
||||||
|
return `
|
||||||
|
<div class="stat-block">
|
||||||
|
<h3>${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}</h3>
|
||||||
|
${data.map(b => `
|
||||||
|
<div class="bar-row">
|
||||||
|
<div class="label status-badge status-${b.status}">${statusLabel(b.status)}</div>
|
||||||
|
<div class="bar"><div style="width:${(b.count / max) * 100}%"></div></div>
|
||||||
|
<div class="count">${b.count}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
$('#stats-body').innerHTML = `
|
||||||
|
<div class="stat-block">
|
||||||
|
<div class="big-number">${s.total_entries}</div>
|
||||||
|
<div style="color:var(--text-dim)">Einträge insgesamt</div>
|
||||||
|
</div>
|
||||||
|
${block('book', s.books.by_status, s.books.total)}
|
||||||
|
${block('series', s.series.by_status, s.series.total)}
|
||||||
|
`;
|
||||||
|
$('#modal-stats').hidden = false;
|
||||||
|
} catch (e) { toast('Stats laden fehlgeschlagen', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bootstrap -------------------------------------------------------
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
wireFilters();
|
||||||
|
wireAddModal();
|
||||||
|
wireDrawer();
|
||||||
|
$('#open-add').addEventListener('click', openAdd);
|
||||||
|
$('#open-stats').addEventListener('click', openStats);
|
||||||
|
$$('[data-close-stats]').forEach(b => b.addEventListener('click', () => { $('#modal-stats').hidden = true; }));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
wire();
|
||||||
|
loadAll();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Lokale Daten
|
||||||
|
|
||||||
|
Hier landet die SQLite-DB `watchstack.db` (wird automatisch angelegt).
|
||||||
|
|
||||||
|
Dieser Ordner ist in `.gitignore`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
SQLAlchemy==2.0.35
|
||||||
|
pydantic==2.9.2
|
||||||
|
httpx==0.27.2
|
||||||
|
python-multipart==0.0.10
|
||||||
|
jinja2==3.1.4
|
||||||
Reference in New Issue
Block a user