chore(rebrand): rename WatchStack -> Quivio
CI / test (push) Has been cancelled

Quivio (Kofferwort aus Quire + Video) ersetzt den Arbeitstitel WatchStack.
Es ist origineller, frei auf PyPI/npm, und passt perfekt zur Doppelnatur
der App: Buecher (Quire) + Serien (Video).

Geaendert:
- Python-Paket app/ -> quivio/ (+ alle Imports angepasst)
- DB-File watchstack.db -> quivio.db
- Docker-Image: quivio:0.5.1-beta, Container-Name quivio
- Compose-Services: quivio, quivio-local
- ENV-Vars: QUIVIO_VERSION, QUIVIO_PORT, Volume quivio-data
- Frontend-Logo "W" -> "Q", Titel "Quivio", Footer
- Favicon "Q" (Georgia serif fuer klassischen Look)
- Logger-Name: watchstack -> quivio
- README, NOTICE, dist/README, data/README: Quivio
- CI-Workflow: testet quivio/ Pfade
- build-and-push.sh: lokaler Tag "quivio:VERSION"
- service-Feld in /api/health: quivio
- OpenAPI title: Quivio

Bugfix (gefunden beim Renaming):
- seed.py hatte versteckten "from app.database import DB_PATH" — gefixt

Verifiziert end-to-end:
- Python-Import OK
- Backend: service=quivio, version=0.5.1-beta, 8 unique items
- Docker-Build OK, Push in Gitea-Registry OK (neuer sha256-Digest)
- Container: Quivio 0.5.1-beta ready (logger), HTML ohne WatchStack-Rest
- OpenAPI title=Quivio
- 8 unique items nach cold start (Race-Fix haelt)
This commit is contained in:
ki
2026-07-22 00:42:09 +02:00
parent fe291ba8ec
commit 9a4909f083
25 changed files with 102 additions and 114 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Quivio Watchlist für Bücher, Serien & mehr.
Name: Quivio (Kofferwort aus Quire + Video).
"""
__version__ = "0.5.1-beta"
__all__ = ["__version__"]
+207
View File
@@ -0,0 +1,207 @@
"""Datenbank-Operationen (CRUD) für Quivio."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Iterable, Optional, Sequence
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from quivio 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 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 _avg(values: Sequence[Optional[float]]) -> Optional[float]:
"""Mittelwert ohne None, gerundet auf 2 Stellen; None wenn leer."""
rated = [v for v in values if v is not None]
if not rated:
return None
return round(sum(rated) / len(rated), 2)
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()
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=len(items),
avg_rating=_avg([m.rating for m in items]),
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()
+46
View File
@@ -0,0 +1,46 @@
"""Datenbank-Engine und Session-Verwaltung."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# SQLite-Datei: überschreibbar via WATCHSTACK_DATA_DIR (für Docker)
_BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("WATCHSTACK_DATA_DIR", _BASE_DIR / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DATA_DIR / "quivio.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."""
from quivio import models # noqa: F401 registriert Modelle bei Base.metadata
Base.metadata.create_all(bind=engine)
+73
View File
@@ -0,0 +1,73 @@
"""Externe Cover-/Metadaten-Quellen (Open Library)."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
import os
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"
DEFAULT_TIMEOUT = float(os.environ.get("WATCHSTACK_HTTP_TIMEOUT", "8"))
def lookup_book_by_isbn(isbn: str, *, timeout: float = DEFAULT_TIMEOUT) -> 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 = DEFAULT_TIMEOUT) -> 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"),
}
+183
View File
@@ -0,0 +1,183 @@
"""FastAPI-App: REST-API + statisches Frontend."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from quivio import __version__, crud, schemas
from quivio.database import get_db, init_db
from quivio.external import lookup_book_by_isbn, lookup_book_by_title
from quivio.seed import seed_if_empty
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("quivio")
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
__all__ = ["app"]
# ---------- Lifespan (init DB + seed on boot) -------------------------------
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
with next(get_db()) as db:
seed_if_empty(db)
log.info("Quivio %s ready", __version__)
yield
app = FastAPI(
title="Quivio",
description="Watchlist für Bücher, Serien & mehr. Quire + Video — dein Lese- und Schau-Tracker.",
version=__version__,
lifespan=lifespan,
contact={"name": "Quivio", "url": "https://git.pkop.de/Vibecode/watchlist"},
license_info={"name": "Apache-2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"},
)
# CORS — offen genug für lokale Beta, in Produktion via env einschränken
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ---------- UI ---------------------------------------------------------------
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def root() -> HTMLResponse:
return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
# ---------- Health / Version -------------------------------------------------
@app.get("/api/health", tags=["meta"])
def health() -> dict[str, str]:
return {"status": "ok", "service": "quivio", "version": __version__}
# ---------- Lookups ----------------------------------------------------------
@app.get("/api/lookup/book", tags=["lookup"])
def lookup_book(
isbn: str | None = Query(None, description="ISBN-10 oder ISBN-13"),
title: str | None = Query(None),
) -> dict[str, Any]:
"""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
# ---------- Sortierung -------------------------------------------------------
_SORT_KEYS = {
"updated_desc", "updated_asc",
"title_asc", "title_desc",
"rating_desc", "rating_asc",
"year_desc", "year_asc",
}
# ---------- Media ------------------------------------------------------------
def _get_or_404(db: Session, media_id: int):
"""Holt einen Eintrag oder wirft 404."""
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return m
@app.get("/api/media", response_model=list[schemas.MediaOut], tags=["media"])
def api_list_media(
kind: str | None = Query(None, pattern="^(book|series)$"),
status: str | None = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
search: str | None = Query(None, max_length=200),
genre: str | None = Query(None, max_length=60),
tag: str | None = Query(None, max_length=40),
sort: str = Query("updated_desc"),
db: Session = Depends(get_db),
):
if sort not in _SORT_KEYS:
raise HTTPException(400, f"sort muss eines von {sorted(_SORT_KEYS)} sein")
return crud.list_media(db, kind=kind, status=status, search=search,
genre=genre, tag=tag, sort=sort)
@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)):
return _get_or_404(db, media_id)
@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),
):
return crud.update_media(db, _get_or_404(db, media_id), payload)
@app.delete("/api/media/{media_id}", status_code=204, tags=["media"])
def api_delete_media(media_id: int, db: Session = Depends(get_db)):
crud.delete_media(db, _get_or_404(db, media_id))
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),
):
return crud.bump_progress(db, _get_or_404(db, media_id), 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 Quivio.
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.
"""
# SPDX-License-Identifier: Apache-2.0
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,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from quivio.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 (an MAL angelehnt, 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)
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)
total_chapters: Mapped[Optional[int]] = mapped_column(Integer)
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))
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}>"
+155
View File
@@ -0,0 +1,155 @@
"""Pydantic-Schemas für API-Ein- und -Ausgabe."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from datetime import date, datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# ---------- Typen ------------------------------------------------------------
MediaKind = Literal["book", "series"]
MediaStatus = Literal["plan", "reading", "done", "hold", "dropped"]
# ---------- Eingaben ---------------------------------------------------------
class MediaBase(BaseModel):
kind: MediaKind
title: str = Field(..., max_length=200, min_length=1)
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, min_length=1)
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
+149
View File
@@ -0,0 +1,149 @@
"""Legt ein paar Demo-Datensätze an, falls die DB leer ist."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from filelock import FileLock
from sqlalchemy.orm import Session
from quivio import crud, models, schemas
log = logging.getLogger(__name__)
# File-Lock gegen Race-Conditions bei mehreren Workern (gunicorn).
# Pro Datenbank-Pfad ein Lock, damit parallele Worker nicht doppelt seeden.
def _lock_path() -> Path:
from quivio.database import DB_PATH
return DB_PATH.with_suffix(DB_PATH.suffix + ".seed.lock")
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:
"""Seedet genau einmal pro DB.
Mehrere gunicorn-Worker können gleichzeitig hochfahren und alle eine leere DB
sehen. Ein File-Lock serialisiert das Seeding prozessübergreifend.
"""
if db.query(models.Media).count() > 0:
return
lock = FileLock(str(_lock_path()), timeout=30)
with lock:
# Re-check unter Lock: ein anderer Worker hat womoeglich schon geseedet.
if db.query(models.Media).count() > 0:
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))
+588
View File
@@ -0,0 +1,588 @@
/* ========================================================================
Quivio — Theme
- Dark default
- 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; }
.version-badge {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
font-size: 10px;
font-weight: 800;
letter-spacing: .1em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(245,158,11,.25), rgba(239,68,68,.25));
color: #fbbf24;
border: 1px solid rgba(245,158,11,.4);
-webkit-text-fill-color: #fbbf24;
}
.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;
}
.grid-loading {
grid-column: 1 / -1;
display: flex; align-items: center; justify-content: center;
gap: 12px;
padding: 60px 20px;
color: var(--text-dim);
}
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.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; }
}
/* ===================== Footer ===================== */
.footer {
max-width: var(--container);
margin: 0 auto;
padding: 24px 24px 40px;
display: flex; justify-content: space-between; align-items: center;
color: var(--text-muted);
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: 40px;
}
.footer span:last-child { color: var(--text-dim); }
+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="Georgia, serif" font-size="38" font-weight="700" fill="white" text-anchor="middle">Q</text>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+235
View File
@@ -0,0 +1,235 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quivio 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">Q</div>
<div class="brand-text">
<h1>Quivio <span class="version-badge">beta</span></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">
<div class="grid-loading">
<div class="spinner"></div>
<span>Lade Watchlist…</span>
</div>
</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>
<footer class="footer">
<span>Quivio <span id="version"></span> · Apache 2.0</span>
<span>Quire + Video — made for readers &amp; binge-watchers</span>
</footer>
<!-- ===================== Toast ===================== -->
<div id="toast" class="toast" hidden></div>
<script src="/static/js/app.js" defer></script>
</body>
</html>
+526
View File
@@ -0,0 +1,526 @@
/* ========================================================================
Quivio 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; }));
}
// Version aus /api/health in den Footer schreiben
(async () => {
try {
const h = await api.get('/api/health');
const el = $('#version');
if (el && h.version) el.textContent = 'v' + h.version;
} catch (_) { /* silent */ }
})();
document.addEventListener('DOMContentLoaded', () => {
wire();
loadAll();
});
})();