From 67f09363f3b7a691c6c0f7a44011fda43bc8d841 Mon Sep 17 00:00:00 2001 From: ki Date: Tue, 21 Jul 2026 23:11:14 +0200 Subject: [PATCH] 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 --- .gitignore | 36 +++ README.md | 75 +++++ app/__init__.py | 0 app/crud.py | 205 ++++++++++++++ app/database.py | 45 +++ app/external.py | 70 +++++ app/main.py | 153 +++++++++++ app/models.py | 146 ++++++++++ app/schemas.py | 152 +++++++++++ app/seed.py | 129 +++++++++ app/static/css/style.css | 543 +++++++++++++++++++++++++++++++++++++ app/static/img/favicon.svg | 10 + app/static/index.html | 227 ++++++++++++++++ app/static/js/app.js | 517 +++++++++++++++++++++++++++++++++++ data/README.md | 5 + requirements.txt | 7 + 16 files changed, 2320 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/crud.py create mode 100644 app/database.py create mode 100644 app/external.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/schemas.py create mode 100644 app/seed.py create mode 100644 app/static/css/style.css create mode 100644 app/static/img/favicon.svg create mode 100644 app/static/index.html create mode 100644 app/static/js/app.js create mode 100644 data/README.md create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95a51bd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d113828 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..1bb8761 --- /dev/null +++ b/app/crud.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..e1763fb --- /dev/null +++ b/app/database.py @@ -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) diff --git a/app/external.py b/app/external.py new file mode 100644 index 0000000..6de4fd9 --- /dev/null +++ b/app/external.py @@ -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"), + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..9449709 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..aeba84e --- /dev/null +++ b/app/models.py @@ -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"" + + +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"" diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..d2f3198 --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..f163d68 --- /dev/null +++ b/app/seed.py @@ -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)) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..6e52c4e --- /dev/null +++ b/app/static/css/style.css @@ -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; } +} diff --git a/app/static/img/favicon.svg b/app/static/img/favicon.svg new file mode 100644 index 0000000..f03f792 --- /dev/null +++ b/app/static/img/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + W + diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..8dc22d2 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,227 @@ + + + + + + WatchStack – Watchlist für Bücher & Serien + + + + + +
+
+
+
+ +
+

WatchStack

+

Deine Watchlist für Bücher, Serien & mehr.

+
+
+ + +
+ + + +
+ + +
+ +
+ Genres: + +
+
+ Tags: + +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..3aead7b --- /dev/null +++ b/app/static/js/app.js @@ -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, '''); + } + + function stars(rating) { + if (rating == null) return ''; + return `★ ${Number(rating).toFixed(1)}`; + } + + 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 = 'Genres:'; + t.innerHTML = 'Tags:'; + 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 => ` +
+
+ ${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'} + ${m.cover_url + ? `${escapeHtml(m.title)}` + : `
${escapeHtml((m.title || '?').slice(0, 28))}
`} +
+
+
+
${escapeHtml(m.title)}
+
${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}
+
+ ${statusLabel(m.status)} + ${stars(m.rating)} +
+
${progressText(m)}
+
+
+ `).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 = ` +
+
+
+ ${m.cover_url + ? `` + : `
${escapeHtml((m.title || '?').slice(0, 20))}
`} +
+
+

${escapeHtml(m.title)}

+
${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}
+
+ ${statusLabel(m.status)} + ${stars(m.rating)} +
+
+
+
+ + ${m.description ? `

Beschreibung

${escapeHtml(m.description)}

` : ''} + +
+

Status & Fortschritt

+
+
+
+
${progressText(m)} (${m.progress_percent}%)
+
+ + + + +
+
+ ${['plan','reading','done','hold','dropped'].map(s => + `` + ).join('')} +
+
+ +
+

Eckdaten

+
+ ${m.kind === 'book' ? ` +
Autor
${escapeHtml(m.author || '—')}
+
ISBN
${escapeHtml(m.isbn || '—')}
+
Bände
${m.total_volumes ?? '—'}
+
Kapitel
${m.total_chapters ?? '—'}
+
Seiten
${m.total_pages ?? '—'}
+ ` : ` +
Sender
${escapeHtml(m.network || '—')}
+
Staffeln
${m.total_seasons ?? '—'}
+
Episoden
${m.total_episodes ?? '—'}
+
Staffel aktuell
${m.season_watching ?? '—'}
+
Start
${m.start_date || '—'}
+
Ende
${m.end_date || '—'}
+ `} +
+
+ + ${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? ` +
+

Genres & Tags

+
+ ${(m.genres||[]).map(g => `${escapeHtml(g.name)}`).join('')} + ${(m.tags||[]).map(t => `#${escapeHtml(t.name)}`).join('')} +
+
+ ` : ''} + + ${m.notes ? `

Notizen

${escapeHtml(m.notes)}

` : ''} + +
+ + +
+ `; + + // 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 ` +
+

${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}

+ ${data.map(b => ` +
+
${statusLabel(b.status)}
+
+
${b.count}
+
+ `).join('')} +
+ `; + }; + $('#stats-body').innerHTML = ` +
+
${s.total_entries}
+
Einträge insgesamt
+
+ ${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(); + }); +})(); diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..46b0d44 --- /dev/null +++ b/data/README.md @@ -0,0 +1,5 @@ +# Lokale Daten + +Hier landet die SQLite-DB `watchstack.db` (wird automatisch angelegt). + +Dieser Ordner ist in `.gitignore`. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59ca07a --- /dev/null +++ b/requirements.txt @@ -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