chore: beta release v0.5.0-beta
- Bump version to 0.5.0-beta - Add Apache-2.0 LICENSE (official text) + NOTICE - Add Docker support: multi-stage Dockerfile + docker-compose * non-root user, tini PID 1, gunicorn + uvicorn workers * healthcheck, OCI labels, persistent /data volume - Add GitHub Actions CI (lint/import-check + Docker smoke-test) - Backend polish: * modern lifespan handler (replaces deprecated on_event) * explicit sort validation (400 instead of 500) * title min_length=1 schema validation * configurable data dir + http timeout via env * avg_rating helper extracted (DRY) - Frontend polish: * beta badge in title, footer with live version * loading spinner on initial fetch * updated README, badges, config table, roadmap
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""WatchStack – Watchlist für Bücher, Serien & mehr."""
|
||||
|
||||
__version__ = "0.5.0-beta"
|
||||
__all__ = ["__version__"]
|
||||
|
||||
+11
-9
@@ -1,4 +1,5 @@
|
||||
"""Datenbank-Operationen (CRUD) für WatchStack."""
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional, Sequence
|
||||
@@ -166,23 +167,24 @@ def bump_progress(db: Session, media: models.Media, payload: schemas.ProgressUpd
|
||||
|
||||
# ---------- 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()
|
||||
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,
|
||||
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()],
|
||||
)
|
||||
|
||||
|
||||
+7
-6
@@ -1,15 +1,17 @@
|
||||
"""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 liegt im data/-Ordner (gitignored).
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
# 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 / "watchstack.db"
|
||||
|
||||
DATABASE_URL = f"sqlite:///{DB_PATH}"
|
||||
@@ -39,7 +41,6 @@ def get_db():
|
||||
|
||||
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]
|
||||
from app import models # noqa: F401 – registriert Modelle bei Base.metadata
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
+5
-2
@@ -1,7 +1,9 @@
|
||||
"""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
|
||||
@@ -10,9 +12,10 @@ 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 = 8.0) -> Optional[dict]:
|
||||
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
|
||||
@@ -41,7 +44,7 @@ def lookup_book_by_isbn(isbn: str, *, timeout: float = 8.0) -> Optional[dict]:
|
||||
}
|
||||
|
||||
|
||||
def lookup_book_by_title(title: str, *, timeout: float = 8.0) -> Optional[dict]:
|
||||
def lookup_book_by_title(title: str, *, timeout: float = DEFAULT_TIMEOUT) -> Optional[dict]:
|
||||
"""Sucht Buchdaten anhand Titel (Fallback)."""
|
||||
if not title:
|
||||
return None
|
||||
|
||||
+76
-46
@@ -1,65 +1,87 @@
|
||||
"""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 Optional
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
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 app import crud, schemas
|
||||
from app import __version__, 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")
|
||||
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",
|
||||
)
|
||||
__all__ = ["app"]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
# ---------- 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("WatchStack %s ready", __version__)
|
||||
yield
|
||||
|
||||
|
||||
# ---------- Statische Dateien & UI ------------------------------------------
|
||||
app = FastAPI(
|
||||
title="WatchStack",
|
||||
description="Watchlist für Bücher, Serien & mehr.",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
contact={"name": "WatchStack", "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(request: Request) -> HTMLResponse:
|
||||
index = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
return HTMLResponse(index)
|
||||
def root() -> HTMLResponse:
|
||||
return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------- Health -----------------------------------------------------------
|
||||
# ---------- Health / Version -------------------------------------------------
|
||||
|
||||
@app.get("/api/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "service": "watchstack", "version": app.version}
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "watchstack", "version": __version__}
|
||||
|
||||
|
||||
# ---------- Metadaten-Lookup -------------------------------------------------
|
||||
# ---------- Lookups ----------------------------------------------------------
|
||||
|
||||
@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:
|
||||
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)
|
||||
@@ -72,20 +94,40 @@ def lookup_book(
|
||||
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: 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),
|
||||
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),
|
||||
):
|
||||
items = crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort)
|
||||
return items
|
||||
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"])
|
||||
@@ -95,10 +137,7 @@ def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)
|
||||
|
||||
@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
|
||||
return _get_or_404(db, media_id)
|
||||
|
||||
|
||||
@app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
|
||||
@@ -107,18 +146,12 @@ def api_update_media(
|
||||
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)
|
||||
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)):
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
crud.delete_media(db, m)
|
||||
crud.delete_media(db, _get_or_404(db, media_id))
|
||||
return None
|
||||
|
||||
|
||||
@@ -128,10 +161,7 @@ def api_progress(
|
||||
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)
|
||||
return crud.bump_progress(db, _get_or_404(db, media_id), payload)
|
||||
|
||||
|
||||
# ---------- Taxonomien -------------------------------------------------------
|
||||
|
||||
+6
-6
@@ -4,6 +4,7 @@ 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
|
||||
@@ -21,7 +22,6 @@ from sqlalchemy import (
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -66,7 +66,7 @@ class Tag(Base):
|
||||
|
||||
# ---------- Hauptentität: Media ----------------------------------------------
|
||||
|
||||
# Status-Werte (an MAL angelehnt, aber für Bücher + Serien nutzbar):
|
||||
# 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)
|
||||
@@ -84,7 +84,7 @@ class Media(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Allgemein
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False) # book|series
|
||||
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)
|
||||
@@ -97,15 +97,15 @@ class Media(Base):
|
||||
# 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_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)) # Sender/Streaming
|
||||
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)
|
||||
|
||||
+6
-3
@@ -1,4 +1,5 @@
|
||||
"""Pydantic-Schemas für API-Ein- und -Ausgabe."""
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
@@ -7,15 +8,17 @@ from typing import List, Literal, Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ---------- Eingaben ---------------------------------------------------------
|
||||
# ---------- Typen ------------------------------------------------------------
|
||||
|
||||
MediaKind = Literal["book", "series"]
|
||||
MediaStatus = Literal["plan", "reading", "done", "hold", "dropped"]
|
||||
|
||||
|
||||
# ---------- Eingaben ---------------------------------------------------------
|
||||
|
||||
class MediaBase(BaseModel):
|
||||
kind: MediaKind
|
||||
title: str = Field(..., max_length=200)
|
||||
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)
|
||||
@@ -53,7 +56,7 @@ class MediaCreate(MediaBase):
|
||||
|
||||
class MediaUpdate(BaseModel):
|
||||
"""Alle Felder optional – PATCH-Semantik."""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
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
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
"""Legt ein paar Demo-Datensätze an, falls die DB leer ist."""
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -6,7 +7,7 @@ from datetime import date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, schemas
|
||||
from app import crud, models, schemas
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,7 +121,7 @@ DEMO_SERIES = [
|
||||
|
||||
|
||||
def seed_if_empty(db: Session) -> None:
|
||||
if db.query(__import__("app").models.Media).count() > 0: # type: ignore[attr-defined]
|
||||
if db.query(models.Media).count() > 0:
|
||||
return
|
||||
log.info("Leere DB – lege Demo-Daten an.")
|
||||
for spec in DEMO_BOOKS:
|
||||
|
||||
@@ -96,6 +96,22 @@ a { color: var(--accent-2); text-decoration: none; }
|
||||
}
|
||||
.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;
|
||||
@@ -224,6 +240,22 @@ a { color: var(--accent-2); text-decoration: none; }
|
||||
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);
|
||||
@@ -541,3 +573,16 @@ body.kind-series .row[data-when="series"] { display: flex; }
|
||||
.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
-2
@@ -15,7 +15,7 @@
|
||||
<div class="brand">
|
||||
<div class="logo">W</div>
|
||||
<div class="brand-text">
|
||||
<h1>WatchStack</h1>
|
||||
<h1>WatchStack <span class="version-badge">beta</span></h1>
|
||||
<p>Deine Watchlist für <strong>Bücher</strong>, <strong>Serien</strong> & mehr.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +74,10 @@
|
||||
|
||||
<!-- Listen-Grid -->
|
||||
<section id="grid" class="grid">
|
||||
<!-- Cards per JS -->
|
||||
<div class="grid-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Lade Watchlist…</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="empty-state" class="empty hidden">
|
||||
@@ -219,6 +222,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<span>WatchStack <span id="version">…</span> · Apache 2.0</span>
|
||||
<span>Made for readers & binge-watchers</span>
|
||||
</footer>
|
||||
|
||||
<!-- ===================== Toast ===================== -->
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
|
||||
@@ -510,6 +510,15 @@
|
||||
$$('[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();
|
||||
|
||||
Reference in New Issue
Block a user