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:
ki
2026-07-21 23:11:14 +02:00
commit 67f09363f3
16 changed files with 2320 additions and 0 deletions
+36
View File
@@ -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
+75
View File
@@ -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**: 110 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
View File
+205
View File
@@ -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()
+45
View File
@@ -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)
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+543
View File
@@ -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; }
}
+10
View File
@@ -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

+227
View File
@@ -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 AZ</option>
<option value="title_desc">Titel ZA</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 (010)</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>
+517
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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();
});
})();
+5
View File
@@ -0,0 +1,5 @@
# Lokale Daten
Hier landet die SQLite-DB `watchstack.db` (wird automatisch angelegt).
Dieser Ordner ist in `.gitignore`.
+7
View File
@@ -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