From cb78225e8601530a96735b120bb78ac2e956f7f5 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 16 Sep 2026 17:00:35 +0200 Subject: [PATCH] update --- .gitignore | 3 + Makefile | 5 +- backend/app/backup.py | 39 + backend/app/main.py | 9 + backend/pytest.ini | 3 + backend/requirements-dev.txt | 1 + backend/tests/conftest.py | 91 ++ backend/tests/test_features.py | 74 ++ backend/tests/test_start.py | 6 + backend/tests/test_suggestions.py | 94 ++ backend/tests/test_users.py | 46 + backend/tests/test_watched.py | 63 ++ frontend/src/App.vue | 848 +++++-------------- frontend/src/components/MovieCard.vue | 17 + frontend/src/components/MovieDetail.vue | 15 + frontend/src/components/SearchTab.vue | 4 + frontend/src/components/SeriesCard.vue | 21 + frontend/src/components/StreamTab.vue | 1 + frontend/src/components/VorschlagsListe.vue | 264 ++++++ frontend/src/components/WatchedTab.vue | 12 +- frontend/src/components/WheelTab.vue | 668 ++------------- frontend/src/components/WuenscheTab.vue | 6 + frontend/src/composables/useEinstellungen.js | 58 ++ frontend/src/composables/useFilter.js | 179 ++++ frontend/src/composables/useKatalog.js | 150 ++++ frontend/src/composables/useLangeListe.js | 81 ++ frontend/src/composables/useMeins.js | 124 +++ frontend/src/composables/useZiehung.js | 287 +++++++ frontend/src/klang.js | 50 ++ 29 files changed, 1992 insertions(+), 1227 deletions(-) create mode 100644 backend/app/backup.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_features.py create mode 100644 backend/tests/test_start.py create mode 100644 backend/tests/test_suggestions.py create mode 100644 backend/tests/test_users.py create mode 100644 backend/tests/test_watched.py create mode 100644 frontend/src/components/VorschlagsListe.vue create mode 100644 frontend/src/composables/useEinstellungen.js create mode 100644 frontend/src/composables/useFilter.js create mode 100644 frontend/src/composables/useKatalog.js create mode 100644 frontend/src/composables/useLangeListe.js create mode 100644 frontend/src/composables/useMeins.js create mode 100644 frontend/src/composables/useZiehung.js create mode 100644 frontend/src/klang.js diff --git a/.gitignore b/.gitignore index 3416bcb..43416b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules/ frontend/dist/ backend/static/ data/ + +backend/backups/ +data/backups/ diff --git a/Makefile b/Makefile index dd00542..f4dc682 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -.PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop +.PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop test install: cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cd frontend && npm install +test: + cd backend && .venv/bin/python -m pytest -q + # MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins # Leere und OBS findet keinen Server. # Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst diff --git a/backend/app/backup.py b/backend/app/backup.py new file mode 100644 index 0000000..7e7c97f --- /dev/null +++ b/backend/app/backup.py @@ -0,0 +1,39 @@ +"""Nächtliche Kopie der Datenbank. + +Sammlungen, Wertungen und Wünsche liegen nur in dieser einen Datei. Die +Kopie entsteht über SQLites eigene Backup-Schnittstelle — die ist auch +dann konsistent, wenn gerade jemand schreibt. Es bleiben die letzten +vierzehn Stände, jeder Tag einer. +""" + +import logging +import sqlite3 +from datetime import date + +from .config import DB_PATH + +log = logging.getLogger("app.backup") + +BEHALTEN = 14 + + +def sichern(): + ordner = DB_PATH.parent / "backups" + ordner.mkdir(parents=True, exist_ok=True) + ziel = ordner / f"{DB_PATH.stem}-{date.today():%Y-%m-%d}{DB_PATH.suffix}" + + quelle = sqlite3.connect(DB_PATH) + kopie = sqlite3.connect(ziel) + try: + quelle.backup(kopie) + finally: + kopie.close() + quelle.close() + + # Die Namen sortieren nach Datum, das Alte steht vorn + alte = sorted(ordner.glob(f"{DB_PATH.stem}-*{DB_PATH.suffix}"))[:-BEHALTEN] + for alt in alte: + alt.unlink() + + log.info("Datenbank gesichert: %s", ziel.name) + return ziel diff --git a/backend/app/main.py b/backend/app/main.py index 4ad6a9b..a725ab1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from . import ( + backup, db, events, features, @@ -69,6 +70,11 @@ app.add_middleware( ) +async def sicherung(): + # Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread + await asyncio.to_thread(backup.sichern) + + async def guarded_sync(): if sync_lock.locked(): log.info("Sync läuft bereits") @@ -85,6 +91,9 @@ async def startup(): watched.init() wishlist.init() features.init() + # Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update + # ein sauberer Stand + scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup") scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync") scheduler.start() if not db.get_meta("last_sync"): diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..9471b3d --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1 @@ +pytest==8.4.2 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..88b105c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,91 @@ +"""Testumgebung: eigene Datenbank, kein Netz. + +Die Umgebung steht, bevor app.* importiert wird — config.py liest DB_PATH +beim Import. Die Schlüssel prüft config.py nur auf Vorhandensein. +""" + +import os +import tempfile +from pathlib import Path + +_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-")) +os.environ["DB_PATH"] = str(_ORDNER / "test.db") +os.environ.setdefault("TMDB_TOKEN", "test") +os.environ.setdefault("MINIMAX_KEY", "test") + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app import db, features, main, suggestions, users, watched, wishlist # noqa: E402 + +# Zählt, ob der Startup doch einen Sync anstoßen wollte +_SYNC_AUFRUFE = [] + + +async def kein_sync(): + _SYNC_AUFRUFE.append(True) + + +@pytest.fixture(scope="session", autouse=True) +def app_start(): + """Startup einmal je Lauf. last_sync steht vorher, damit main.startup + keinen TMDB-Sync startet; guarded_sync ist zur Sicherheit ersetzt.""" + db.init() + with db.connect() as conn: + db.set_meta(conn, "last_sync", "test") + main.guarded_sync = kein_sync + with TestClient(main.app): + yield + + +@pytest.fixture +def sync_aufrufe(): + return _SYNC_AUFRUFE + + +@pytest.fixture(autouse=True) +def frische_db(app_start): + """Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung.""" + db.DB_PATH.unlink(missing_ok=True) + for modul in (db, users, suggestions, watched, wishlist, features): + modul.init() + main.letzter_dreh.update(ergebnis=None, start=0.0) + + +# Ein Client je Person — jeder hält sein eigenes Cookie +@pytest.fixture +def client(): + return TestClient(main.app) + + +@pytest.fixture +def client2(): + return TestClient(main.app) + + +@pytest.fixture +def film(): + """Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück.""" + + def anlegen(movie_id, title=None): + with db.connect() as conn: + db.upsert(conn, {"id": movie_id, "title": title or f"Film {movie_id}"}) + return movie_id + + return anlegen + + +@pytest.fixture +def anmelden(): + """Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt.""" + + def waehlen(client, name): + antwort = client.post("/api/users", json={"name": name}) + if antwort.status_code == 200: + user_id = antwort.json()["id"] + else: + runde = client.get("/api/users").json()["items"] + user_id = next(u["id"] for u in runde if u["name"] == name) + return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"] + + return waehlen diff --git a/backend/tests/test_features.py b/backend/tests/test_features.py new file mode 100644 index 0000000..ed99d5d --- /dev/null +++ b/backend/tests/test_features.py @@ -0,0 +1,74 @@ +"""Wünsche: Stimmen, Abhaken, Anmerkungen.""" + +from app import db + + +def wunsch(client, text="Dunkles Design"): + liste = client.post("/api/features", json={"text": text}).json() + return next(w for w in liste if w["text"] == text) + + +def test_leerer_wunsch_wird_abgelehnt(client): + assert client.post("/api/features", json={"text": " "}).status_code == 400 + + +def test_stimme_toggelt(client): + w = wunsch(client) + assert w["mine"] is True + assert w["votes"] == 0 + assert w["voted"] is False + + assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": True} + [w] = client.get("/api/features").json() + assert w["votes"] == 1 + assert w["voted"] is True + + assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": False} + [w] = client.get("/api/features").json() + assert w["votes"] == 0 + assert w["voted"] is False + + +def test_erledigt_rutscht_ans_ende(client): + erster = wunsch(client, "A") + wunsch(client, "B") + # Mit Stimme steht A vorn + client.post(f"/api/features/{erster['id']}/vote") + assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"] + + antwort = client.patch(f"/api/features/{erster['id']}/done", json={"done": True}) + assert antwort.json() == {"ok": True} + liste = client.get("/api/features").json() + assert [w["text"] for w in liste] == ["B", "A"] + assert liste[1]["done_at"] is not None + + client.patch(f"/api/features/{erster['id']}/done", json={"done": False}) + assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"] + + +def test_anmerkungen_anlegen_und_loeschen(client, client2): + w = wunsch(client) + antwort = client.post(f"/api/features/{w['id']}/notes", json={"text": "Mit Kontrast"}) + assert antwort.json() == {"ok": True} + + [w] = client.get("/api/features").json() + [notiz] = w["notes"] + assert notiz["text"] == "Mit Kontrast" + assert notiz["mine"] is True + [fremd] = client2.get("/api/features").json() + assert fremd["notes"][0]["mine"] is False + + assert client.delete(f"/api/feature-notes/{notiz['id']}").json() == {"removed": 1} + assert client.get("/api/features").json()[0]["notes"] == [] + + +def test_loeschen_raeumt_stimmen_und_anmerkungen_mit(client): + w = wunsch(client) + client.post(f"/api/features/{w['id']}/vote") + client.post(f"/api/features/{w['id']}/notes", json={"text": "Anmerkung"}) + + assert client.delete(f"/api/features/{w['id']}").json() == {"removed": 1} + assert client.get("/api/features").json() == [] + with db.connect() as conn: + for tabelle in ("feature_votes", "feature_notes"): + assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0 diff --git a/backend/tests/test_start.py b/backend/tests/test_start.py new file mode 100644 index 0000000..2c882ed --- /dev/null +++ b/backend/tests/test_start.py @@ -0,0 +1,6 @@ +"""Der Startup läuft im Test ohne TMDB-Sync.""" + + +def test_startup_startet_keinen_sync(client, sync_aufrufe): + assert client.get("/api/health").json() == {"ok": True} + assert sync_aufrufe == [] diff --git a/backend/tests/test_suggestions.py b/backend/tests/test_suggestions.py new file mode 100644 index 0000000..5c58c26 --- /dev/null +++ b/backend/tests/test_suggestions.py @@ -0,0 +1,94 @@ +"""Die Ziehung: Streifen, Stopps, Gewinner.""" + +from app import suggestions +from app.suggestions import BRUCH_DAUER, DREH_DAUER, STREIFEN_LAENGE, STREIFEN_ZIEL + + +def vorschlagen(client, movie_id): + antwort = client.post("/api/suggestions", json={"movie_id": movie_id}) + assert antwort.status_code == 200 + return antwort.json() + + +def test_zweiter_vorschlag_ersetzt_den_ersten(client, film): + vorschlagen(client, film(1)) + neu = vorschlagen(client, film(2)) + + liste = client.get("/api/suggestions").json() + assert [e["movie_id"] for e in liste["items"]] == [2] + assert liste["mine"] == neu["id"] + + +def test_dreh_liefert_streifen_und_gewinner(client, client2, film): + vorschlagen(client, film(1)) + vorschlagen(client2, film(2)) + + ergebnis = client.post("/api/spin").json() + + assert ergebnis["started"] is True + assert [k["movie_id"] for k in ergebnis["karten"]] == [1, 2] + assert len(ergebnis["streifen"]) == STREIFEN_LAENGE + assert set(ergebnis["streifen"]) <= {0, 1} + stopps = ergebnis["stopps"] + assert len(stopps) in (1, 2) + assert all(set(s) == {"position", "versatz"} for s in stopps) + assert stopps[0]["position"] == STREIFEN_ZIEL + assert ergebnis["gewinner"] == ergebnis["karten"][ergebnis["index"]] + + +def test_ziehung_haelt_auf_dem_gewinner(client, client2, film): + vorschlagen(client, film(1)) + vorschlagen(client2, film(2)) + + stopp_anzahlen = set() + for _ in range(60): + e = suggestions.ziehen() + stopps = e["stopps"] + stopp_anzahlen.add(len(stopps)) + # Der letzte Halt zeigt den Gewinner + assert e["streifen"][stopps[-1]["position"]] == e["index"] + assert all(-0.5 < s["versatz"] < 0.5 for s in stopps) + if len(stopps) == 2: + # Bruch: ein paar Poster zurück, der erste Halt zeigt einen anderen + assert 1 <= stopps[0]["position"] - stopps[1]["position"] <= 4 + assert e["streifen"][stopps[0]["position"]] != e["index"] + assert e["dauer"] == DREH_DAUER + BRUCH_DAUER + else: + assert e["dauer"] == DREH_DAUER + # Beide Fälle kamen vor + assert stopp_anzahlen == {1, 2} + + +def test_zweiter_dreh_waehrend_laufender_ziehung(client, film): + vorschlagen(client, film(1)) + assert client.post("/api/spin").json()["started"] is True + + assert client.post("/api/spin").json()["started"] is False + + stand = client.get("/api/spin").json() + assert stand["running"] is True + assert stand["elapsed"] >= 0 + assert stand["result"]["gewinner"]["movie_id"] == 1 + + +def test_ohne_vorschlaege_kein_dreh(client): + assert client.post("/api/spin").status_code == 400 + assert client.get("/api/spin").json() == {"running": False, "result": None} + + +def test_gesehene_filme_gewinnen_nicht(client, client2, film): + vorschlagen(client, film(1)) + vorschlagen(client2, film(2)) + client.post("/api/watched", json={"movie_id": 1}) + + for _ in range(50): + assert suggestions.ziehen()["gewinner"]["movie_id"] == 2 + + +def test_runde_zuruecksetzen(client, film): + vorschlagen(client, film(1)) + client.post("/api/spin") + + assert client.delete("/api/suggestions/alle").json() == {"removed": 1} + assert client.get("/api/spin").json()["running"] is False + assert client.get("/api/suggestions").json()["items"] == [] diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..ecb828d --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,46 @@ +"""Besitz: Einträge hängen am Namen, ohne Namen an der Sitzung.""" + + +def test_gleicher_name_in_anderem_browser_sieht_eigene_eintraege( + client, client2, anmelden, film +): + anmelden(client, "Anna") + vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json() + client.post("/api/wishlist", json={"movie_id": film(2)}) + client.post("/api/features", json={"text": "Dunkles Design"}) + + # Anna am zweiten Gerät + anmelden(client2, "Anna") + assert client2.get("/api/suggestions").json()["mine"] == vorschlag["id"] + assert client2.get("/api/wishlist").json()[0]["mine"] is True + assert client2.get("/api/features").json()[0]["mine"] is True + + # Ben nicht + anmelden(client2, "Ben") + assert client2.get("/api/suggestions").json()["mine"] is None + assert client2.get("/api/wishlist").json()[0]["mine"] is False + assert client2.get("/api/features").json()[0]["mine"] is False + + +def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, film): + anmelden(client, "Anna") + als_anna = client.post("/api/suggestions", json={"movie_id": film(1)}).json() + assert als_anna["name"] == "Anna" + + anmelden(client, "Ben") + assert client.get("/api/suggestions").json()["mine"] is None + # Bens Vorschlag ersetzt Annas nicht — einer je Name, nicht je Browser + als_ben = client.post("/api/suggestions", json={"movie_id": film(2)}).json() + liste = client.get("/api/suggestions").json() + assert [e["name"] for e in liste["items"]] == ["Anna", "Ben"] + assert liste["mine"] == als_ben["id"] + + anmelden(client, "Anna") + assert client.get("/api/suggestions").json()["mine"] == als_anna["id"] + + +def test_ohne_namen_zaehlt_die_sitzung(client, client2, film): + vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json() + assert vorschlag["name"] is None + assert client.get("/api/suggestions").json()["mine"] == vorschlag["id"] + assert client2.get("/api/suggestions").json()["mine"] is None diff --git a/backend/tests/test_watched.py b/backend/tests/test_watched.py new file mode 100644 index 0000000..f2018fe --- /dev/null +++ b/backend/tests/test_watched.py @@ -0,0 +1,63 @@ +"""Wertungen: Sterne, Text, Schnitt.""" + +from app.watched import MAX_TEXT + + +def gesehen(client, film, movie_id=1): + """Film anlegen und als gesehen eintragen. Gibt die watched-id zurück.""" + film(movie_id) + return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"] + + +def test_wertung_mit_text(client, film): + wid = gesehen(client, film) + + antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"}) + assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"} + + [eintrag] = client.get("/api/watched").json() + assert eintrag["average"] == 4.0 + assert eintrag["count"] == 1 + assert eintrag["mine"] == 4 + assert eintrag["mine_text"] == "Gruselig" + assert eintrag["ratings"][0]["mine"] is True + + +def test_text_none_behaelt_leer_loescht(client, film): + wid = gesehen(client, film) + client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"}) + + # Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme + client.post(f"/api/watched/{wid}/rating", json={"stars": 3}) + [eintrag] = client.get("/api/watched").json() + assert eintrag["mine"] == 3 + assert eintrag["mine_text"] == "Gruselig" + assert eintrag["count"] == 1 + + client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""}) + [eintrag] = client.get("/api/watched").json() + assert eintrag["mine_text"] is None + + +def test_text_wird_gekuerzt(client, film): + wid = gesehen(client, film) + client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)}) + + [eintrag] = client.get("/api/watched").json() + assert len(eintrag["mine_text"]) == MAX_TEXT + + +def test_fremde_wertung_ist_nicht_meine(client, client2, film): + wid = gesehen(client, film) + client.post(f"/api/watched/{wid}/rating", json={"stars": 4}) + + [eintrag] = client2.get("/api/watched").json() + assert eintrag["mine"] is None + assert eintrag["count"] == 1 + assert eintrag["ratings"][0]["mine"] is False + + client2.post(f"/api/watched/{wid}/rating", json={"stars": 2}) + [eintrag] = client2.get("/api/watched").json() + assert eintrag["average"] == 3.0 + assert eintrag["count"] == 2 + assert eintrag["mine"] == 2 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9631b11..b2276a1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,5 @@ @@ -839,7 +342,7 @@ onUnmounted(() => { padding-right: calc(var(--sidebar) + 1.25rem); } -/* Such- und Rad-Reiter haben keine Filterleiste */ +/* Reiter ohne Filterleiste */ .page.breit { padding-right: 1.25rem; } @@ -886,6 +389,7 @@ onUnmounted(() => { padding: 0.8rem 1rem 0.6rem; color: var(--muted); font-size: 0.95rem; + white-space: nowrap; border-bottom: 2px solid transparent; } @@ -898,6 +402,12 @@ onUnmounted(() => { border-bottom-color: var(--accent); } +/* Den Filterknopf gibt es nur auf schmalen Schirmen — mit .tabs davor, + sonst gewinnt die Regel für alle Reiterknöpfe */ +.tabs .filter-knopf { + display: none; +} + .host { align-self: center; margin-left: 0.4rem; @@ -938,26 +448,6 @@ onUnmounted(() => { border-left: 1px solid var(--line); } -@media (max-width: 900px) { - .page { - padding-right: 1.25rem; - } - - /* Zu schmal für eine feste Leiste — die Filter wandern über die Kacheln */ - .layout { - display: flex; - flex-direction: column; - } - - .layout > :last-child { - position: static; - order: -1; - width: auto; - border: 1px solid var(--line); - border-radius: 12px; - } -} - .nachschub { padding: 2rem 0; color: var(--muted); @@ -990,4 +480,82 @@ onUnmounted(() => { color: var(--accent); } +/* ── Schmale Schirme ─────────────────────────────────────────────── */ +@media (max-width: 900px) { + .page { + padding-right: 1.25rem; + } + + .nach-oben { + right: 1rem; + bottom: 1rem; + } + + /* Zu schmal für eine feste Leiste — die Filter klappen über den Karten auf */ + .layout { + display: flex; + flex-direction: column; + } + + .layout > :last-child { + display: none; + position: static; + order: -1; + width: auto; + margin-bottom: 1.25rem; + border: 1px solid var(--line); + border-radius: 12px; + } + + .layout > :last-child.offen { + display: flex; + } + + .tabs .filter-knopf { + display: flex; + margin-left: auto; + } + + .tabs .filter-knopf.an { + color: var(--accent); + } + + /* Die Namenswahl rückt neben den Filterknopf */ + .filter-knopf + * { + margin-left: 0; + } +} + +@media (max-width: 640px) { + .page { + padding: 1rem 0.75rem 2.5rem; + } + + /* Die Reiter passen nicht in eine Zeile — sie scrollen seitlich */ + .tabs { + gap: 0; + margin: -1rem -0.75rem 1rem; + padding: 0 0.75rem; + overflow-x: auto; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tabs button { + padding: 0.7rem 0.6rem 0.5rem; + font-size: 0.88rem; + } + + .grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.7rem; + } + + .grid.details { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/components/MovieCard.vue b/frontend/src/components/MovieCard.vue index 6db54c3..d8bac31 100644 --- a/frontend/src/components/MovieCard.vue +++ b/frontend/src/components/MovieCard.vue @@ -258,4 +258,21 @@ h2 { .card.details .shots { overflow: hidden; } + +/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */ +@media (max-width: 700px) { + .card.details { + grid-template-columns: 110px minmax(0, 1fr); + grid-template-rows: auto auto; + } + + .card.details .poster { + width: 110px; + } + + .card.details .shots { + grid-column: 1 / -1; + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/frontend/src/components/MovieDetail.vue b/frontend/src/components/MovieDetail.vue index a0be21c..88330d3 100644 --- a/frontend/src/components/MovieDetail.vue +++ b/frontend/src/components/MovieDetail.vue @@ -701,6 +701,17 @@ h3 { } @media (max-width: 620px) { + .backdrop { + padding: 0; + } + + .sheet { + min-height: 100%; + padding: 1rem; + border: 0; + border-radius: 0; + } + header { flex-direction: column; } @@ -712,6 +723,10 @@ h3 { .shots { grid-template-columns: 1fr; } + + .lupe { + padding: 0.5rem; + } } .shot { diff --git a/frontend/src/components/SearchTab.vue b/frontend/src/components/SearchTab.vue index 7c87a5b..ac98b4b 100644 --- a/frontend/src/components/SearchTab.vue +++ b/frontend/src/components/SearchTab.vue @@ -151,6 +151,9 @@ async function open(movie) { .big-search { flex: 1; + /* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus + dem Bild, wenn der Schirm schmal ist */ + min-width: 0; padding: 0.9rem 2.6rem 0.9rem 1.1rem; font-size: 1.05rem; } @@ -170,6 +173,7 @@ async function open(movie) { } .neu { + flex-shrink: 0; padding: 0 1.4rem; font-size: 0.95rem; font-weight: 600; diff --git a/frontend/src/components/SeriesCard.vue b/frontend/src/components/SeriesCard.vue index bcf3b0c..3ae5b00 100644 --- a/frontend/src/components/SeriesCard.vue +++ b/frontend/src/components/SeriesCard.vue @@ -360,4 +360,25 @@ h2:hover { .card.details .shots { overflow: hidden; } + +/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */ +@media (max-width: 700px) { + .card.details { + grid-template-columns: 110px minmax(0, 1fr); + grid-template-rows: auto auto; + } + + .card.details .poster { + width: 110px; + } + + .card.details .shots { + grid-area: 2 / 1 / 3 / 3; + grid-template-columns: repeat(4, 1fr); + } + + .card.details .body { + grid-area: 1 / 2; + } +} diff --git a/frontend/src/components/StreamTab.vue b/frontend/src/components/StreamTab.vue index c8d5c4e..a0c1313 100644 --- a/frontend/src/components/StreamTab.vue +++ b/frontend/src/components/StreamTab.vue @@ -257,6 +257,7 @@ onUnmounted(() => { .rahmen { position: relative; + max-width: 100%; /* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */ max-height: calc(100vh - 5.5rem); aspect-ratio: 16 / 9; diff --git a/frontend/src/components/VorschlagsListe.vue b/frontend/src/components/VorschlagsListe.vue new file mode 100644 index 0000000..514add9 --- /dev/null +++ b/frontend/src/components/VorschlagsListe.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/frontend/src/components/WatchedTab.vue b/frontend/src/components/WatchedTab.vue index 148063d..b139fe3 100644 --- a/frontend/src/components/WatchedTab.vue +++ b/frontend/src/components/WatchedTab.vue @@ -369,7 +369,7 @@ onUnmounted(() => { diff --git a/frontend/src/components/WheelTab.vue b/frontend/src/components/WheelTab.vue index c7357f0..97ba55b 100644 --- a/frontend/src/components/WheelTab.vue +++ b/frontend/src/components/WheelTab.vue @@ -1,9 +1,12 @@ @@ -546,7 +200,8 @@ async function oeffnen(item) { .fenster { position: relative; align-self: stretch; - height: 370px; + /* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */ + height: calc(var(--kachel) * 1.5 + 40px); overflow: hidden; /* Links und rechts läuft der Streifen ins Dunkel aus */ mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); @@ -557,7 +212,7 @@ async function oeffnen(item) { top: 20px; left: 50%; display: flex; - gap: 10px; + gap: var(--luecke); will-change: transform; /* Die Kurve setzt das Skript je Phase */ transition-property: transform; @@ -565,8 +220,8 @@ async function oeffnen(item) { .kachel { position: relative; - flex: 0 0 220px; - height: 330px; + flex: 0 0 var(--kachel); + height: calc(var(--kachel) * 1.5); overflow: hidden; background: var(--bg-soft); border: 2px solid; @@ -634,6 +289,28 @@ async function oeffnen(item) { pointer-events: none; } +.marke::before, +.marke::after { + position: absolute; + left: 50%; + width: 0; + height: 0; + margin-left: -9px; + content: ''; + border-right: 9px solid transparent; + border-left: 9px solid transparent; +} + +.marke::before { + top: 0; + border-top: 14px solid var(--accent); +} + +.marke::after { + bottom: 0; + border-bottom: 14px solid var(--accent); +} + /* Im Anlauf pulst die Marke dreimal — gleich geht es los */ .marke.anlauf { animation: puls 0.33s ease-in-out 3; @@ -667,28 +344,6 @@ async function oeffnen(item) { } } -.marke::before, -.marke::after { - position: absolute; - left: 50%; - width: 0; - height: 0; - margin-left: -9px; - content: ''; - border-right: 9px solid transparent; - border-left: 9px solid transparent; -} - -.marke::before { - top: 0; - border-top: 14px solid var(--accent); -} - -.marke::after { - bottom: 0; - border-bottom: 14px solid var(--accent); -} - .leer-bahn { position: absolute; inset: 0; @@ -702,6 +357,21 @@ async function oeffnen(item) { gap: 0.7rem; } +.dreh { + padding: 0.7rem 2.2rem; + color: #fff; + font-size: 1rem; + font-weight: 600; + background: var(--accent); + border-radius: 8px; +} + +.dreh:disabled { + background: var(--line); + color: var(--muted); + cursor: default; +} + .reset { padding: 0.7rem 1.4rem; color: var(--muted); @@ -721,73 +391,6 @@ async function oeffnen(item) { cursor: default; } -.leer { - color: var(--muted); - font-size: 0.85rem; -} - -.dreh { - padding: 0.7rem 2.2rem; - color: #fff; - font-size: 1rem; - font-weight: 600; - background: var(--accent); - border-radius: 8px; -} - -.dreh:disabled { - background: var(--line); - color: var(--muted); - cursor: default; -} - -.sammlung-wuerfeln { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1.6rem; -} - -.schalter { - display: flex; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; -} - -.schalter button { - flex: 1; - padding: 0.45rem 0.9rem; - color: var(--muted); - font-size: 0.85rem; - font-weight: 600; - background: var(--bg); -} - -.schalter button.an { - color: var(--text); - background: var(--bg-soft); -} - -.wuerfeln { - padding: 0.5rem 1.1rem; - font-size: 0.88rem; - font-weight: 600; - background: var(--bg); - border: 1px solid var(--line); - border-radius: 8px; -} - -.wuerfeln:hover:not(:disabled) { - border-color: var(--accent); -} - -.wuerfeln:disabled { - color: var(--muted); - cursor: not-allowed; - opacity: 0.5; -} - .gewinner { display: flex; gap: 1rem; @@ -837,111 +440,6 @@ async function oeffnen(item) { font-size: 0.85rem; } -.liste h3 { - margin-bottom: 0.7rem; - color: var(--muted); - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.anzahl { - padding: 0.1rem 0.45rem; - background: var(--bg-soft); - border: 1px solid var(--line); - border-radius: 20px; -} - -.liste ul { - display: flex; - flex-direction: column; - gap: 0.2rem; - list-style: none; -} - -.eintrag { - display: flex; - gap: 0.7rem; - align-items: flex-start; - width: 100%; - padding: 0.55rem; - color: var(--text); - font-size: 0.86rem; - text-align: left; - border-radius: 8px; -} - -/* Schon gelaufen — die Ziehung überspringt diesen Film */ -.eintrag.gesehen { - opacity: 0.45; - filter: grayscale(1); -} - -.eintrag.gesehen:hover { - opacity: 1; - filter: none; -} - -.eintrag:hover { - background: var(--bg-soft); -} - -/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */ -.cover { - display: block; - width: 46px; - flex-shrink: 0; - overflow: hidden; - background: var(--bg-soft); - border-left: 3px solid; - border-radius: 3px; -} - -.cover img { - display: block; - width: 100%; - aspect-ratio: 2 / 3; - object-fit: cover; -} - -.daten { - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.15rem; - padding-top: 0.1rem; -} - -.titel { - overflow: hidden; - font-weight: 600; - line-height: 1.25; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.eintrag.eigen .titel { - color: var(--accent); -} - -.meta, -.genres, -.von { - overflow: hidden; - color: var(--muted); - font-size: 0.74rem; - white-space: nowrap; - text-overflow: ellipsis; -} - -.von { - margin-top: 0.15rem; - font-size: 0.72rem; - opacity: 0.75; -} - .note { padding: 3rem 0; color: var(--muted); diff --git a/frontend/src/components/WuenscheTab.vue b/frontend/src/components/WuenscheTab.vue index 06e87c2..d577aa6 100644 --- a/frontend/src/components/WuenscheTab.vue +++ b/frontend/src/components/WuenscheTab.vue @@ -504,6 +504,12 @@ h3 { opacity: 1; } +@media (hover: none) { + .aktionen { + opacity: 1; + } +} + .weg { padding: 0 0.25rem; color: var(--muted); diff --git a/frontend/src/composables/useEinstellungen.js b/frontend/src/composables/useEinstellungen.js new file mode 100644 index 0000000..6213147 --- /dev/null +++ b/frontend/src/composables/useEinstellungen.js @@ -0,0 +1,58 @@ +// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht, +// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage, +// alles je Browser. +import { onMounted, onUnmounted, ref, watch } from 'vue' + +const TAB_IDS = [ + 'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched', + 'wuensche', 'verwaltung', 'stream', +] + +function gemerkt(schluessel, standard) { + const wert = ref(localStorage.getItem(schluessel) ?? standard) + watch(wert, (neu) => localStorage.setItem(schluessel, neu)) + return wert +} + +export function useEinstellungen() { + // Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film + const ansicht = gemerkt('filmabend_ansicht', 'kompakt') + + // Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus. + // Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten. + const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus') + watch(ohneGesehene, (an) => + localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus') + ) + + // Nach dem Neuladen soll derselbe Reiter offen sein + const zuletzt = localStorage.getItem('filmabend_tab') + const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix') + watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id)) + + // Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login — + // die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen. + const host = ref(localStorage.getItem('filmabend_host') === '1') + + // Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht + // ein Gast sie weiter, ohne dass ein Reiter markiert wäre. + watch(host, (an) => { + const weg = an ? 'merkliste' : 'verwaltung' + if (activeTab.value === weg) activeTab.value = 'netflix' + }) + + function tastatur(e) { + // e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst + // je nach System ein anderes Zeichen + if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') { + e.preventDefault() + host.value = !host.value + localStorage.setItem('filmabend_host', host.value ? '1' : '0') + } + } + + onMounted(() => window.addEventListener('keydown', tastatur)) + onUnmounted(() => window.removeEventListener('keydown', tastatur)) + + return { ansicht, ohneGesehene, activeTab, host } +} diff --git a/frontend/src/composables/useFilter.js b/frontend/src/composables/useFilter.js new file mode 100644 index 0000000..e1b62f5 --- /dev/null +++ b/frontend/src/composables/useFilter.js @@ -0,0 +1,179 @@ +// Die Filterleiste: Genres, Regler, Sortierung, Suche — und die Prädikate, +// mit denen der Katalog danach gesiebt wird. +import { computed, ref, watch } from 'vue' + +// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters — +// sonst wandern die Enden bei jeder Auswahl +export const REGLER = [ + { key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true }, + { key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' }, + { key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true }, + { key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 }, +] + +// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten +// ließen — TMDB reicht von den Anfängen bis heute +const SUCH_GRENZEN = { + vote_count: { min: 50, max: 50000 }, + runtime: { min: 0, max: 240 }, + year: { min: 1900, max: new Date().getFullYear() + 1 }, + vote_average: { min: 0, max: 10 }, +} + +// Die 19 Genres, die TMDB kennt +const SUCH_GENRES = [ + 'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie', + 'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm', + 'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller', + 'Western', +].map((genre) => ({ genre, count: null })) + +export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) { + const search = ref('') + const sort = ref('random') + // Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert + // dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter + // "More like this" zeigt. Im Katalog gibt es die nicht. + const suchSort = ref('relevanz') + const include = ref([]) + const exclude = ref([]) + + const suchReiter = () => activeTab.value === 'search' || activeTab.value === 'aehnlich' + const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value) + + const grenzen = computed(() => { + if (suchReiter()) return SUCH_GRENZEN + const out = {} + for (const regler of REGLER) { + const werte = pool().map((m) => m[regler.feld]).filter((v) => v != null) + const min = werte.length ? Math.min(...werte) : 0 + const max = werte.length ? Math.max(...werte) : 0 + out[regler.key] = { + min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min), + max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max), + } + } + return out + }) + + const ranges = ref({}) + + function resetRanges() { + ranges.value = Object.fromEntries( + Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]]) + ) + } + + // Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala + watch(grenzen, resetRanges, { immediate: true }) + + function imBereich(movie) { + for (const regler of REGLER) { + const spanne = ranges.value[regler.key] + const grenze = grenzen.value[regler.key] + if (!spanne) continue + // Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren + if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue + const wert = movie[regler.feld] + if (wert == null || wert < spanne[0] || wert > spanne[1]) return false + } + return true + } + + // Nur Genres anbieten, die im offenen Reiter auch Treffer haben + const genreOptions = computed(() => { + if (suchReiter()) return SUCH_GENRES + const counts = new Map() + for (const movie of pool()) { + for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1) + } + // Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet. + // Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel. + return [...counts.entries()] + .sort((a, b) => a[0].localeCompare(b[0], 'de')) + .map(([genre, count]) => ({ genre, count })) + }) + + function toggleGenre(kind, genre) { + const liste = kind === 'include' ? include : exclude + const andere = kind === 'include' ? exclude : include + const at = liste.value.indexOf(genre) + if (at === -1) { + liste.value = [...liste.value, genre] + // Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden + andere.value = andere.value.filter((g) => g !== genre) + } else { + liste.value = liste.value.filter((g) => g !== genre) + } + } + + function toggleAll(kind) { + const liste = kind === 'include' ? include : exclude + const andere = kind === 'include' ? exclude : include + const alle = genreOptions.value.map((o) => o.genre) + // Sind schon alle drin, ist der zweite Klick ein Leeren + if (liste.value.length === alle.length) { + liste.value = [] + } else { + liste.value = alle + andere.value = [] + } + } + + function resetGenres() { + include.value = [] + exclude.value = [] + resetRanges() + } + + // Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will + // beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm + // mit Komödie fliegt raus, wenn Komödie ausgeblendet ist. + function hasGenre(movie) { + const genres = movie.genres || [] + if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) { + return false + } + return !include.value.length || genres.some((g) => include.value.includes(g)) + } + + function nichtGesehen(movie) { + return !ohneGesehene.value || !gesehenIds.value.has(movie.id) + } + + function passt(movie) { + return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie) + } + + // Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB + const suchFilter = computed(() => { + const spanne = (key) => ranges.value[key] || [] + return { + sort: suchSort.value, + include: include.value, + exclude: exclude.value, + stimmen: spanne('vote_count'), + dauer: spanne('runtime'), + jahr: spanne('year'), + note: spanne('vote_average'), + grenzen: grenzen.value, + } + }) + + return { + search, + sort, + suchSort, + include, + exclude, + grenzen, + ranges, + genreOptions, + toggleGenre, + toggleAll, + resetGenres, + nichtGesehen, + passt, + suchFilter, + } +} diff --git a/frontend/src/composables/useKatalog.js b/frontend/src/composables/useKatalog.js new file mode 100644 index 0000000..1605681 --- /dev/null +++ b/frontend/src/composables/useKatalog.js @@ -0,0 +1,150 @@ +// Der Katalog: alle Filme aus der Datenbank, aufgeteilt in Netflix und +// Kanon, zu Reihen gruppiert und sortierbar. +import { computed, ref } from 'vue' + +// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben. +// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich. +const MIN_VOTES = 150 + +// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die +// Karten bei jedem Filtern neu durcheinander. +const wuerfel = new Map() + +function zufall(movie) { + if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random()) + return wuerfel.get(movie.id) +} + +// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert. +// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln. +function groupBy(movies, schluessel) { + const byKey = new Map() + const einzeln = [] + for (const movie of movies) { + const key = schluessel(movie) + if (key === null || key === undefined) { + einzeln.push(movie) + continue + } + if (!byKey.has(key)) byKey.set(key, []) + byKey.get(key).push(movie) + } + + const gruppen = [] + for (const [key, parts] of byKey) { + // Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe + if (parts.length === 1) einzeln.push(parts[0]) + else gruppen.push({ group: key, parts }) + } + return { gruppen, einzeln } +} + +export function matches(movie, term) { + return ( + movie.title.toLowerCase().includes(term) || + (movie.original_title || '').toLowerCase().includes(term) + ) +} + +export function useKatalog() { + const movies = ref([]) + const status = ref(null) + const loading = ref(true) + const error = ref('') + + async function load() { + loading.value = true + error.value = '' + try { + const [moviesRes, statusRes] = await Promise.all([ + fetch('/api/movies'), + fetch('/api/status'), + ]) + if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden') + movies.value = await moviesRes.json() + status.value = await statusRes.json() + } catch (err) { + error.value = err.message + } finally { + loading.value = false + } + } + + const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix)) + const canonMovies = computed(() => + movies.value.filter((m) => m.canon_group !== null) + ) + + const average = computed(() => { + const rated = movies.value.filter((m) => m.vote_count) + if (!rated.length) return 0 + return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length + }) + + function score(movie) { + const votes = movie.vote_count || 0 + return ( + (votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) + + (MIN_VOTES / (votes + MIN_VOTES)) * average.value + ) + } + + const SORTS = { + random: (a, b) => zufall(a) - zufall(b), + rating: (a, b) => score(b) - score(a), + newest: (a, b) => (b.year || 0) - (a.year || 0), + oldest: (a, b) => (a.year || 9999) - (b.year || 9999), + title: (a, b) => a.title.localeCompare(b.title, 'de'), + } + + function sortiereReihe(parts) { + const sorted = [...parts].sort( + (a, b) => + (a.release_date || '').localeCompare(b.release_date || '') || + (a.canon_pos ?? 0) - (b.canon_pos ?? 0) + ) + // Die Karte startet beim stärksten Teil, danach sortiert auch die + // Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt + let best = 0 + sorted.forEach((part, i) => { + if (score(part) > score(sorted[best])) best = i + }) + return { parts: sorted, best } + } + + // Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. + // Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach + // Jahr — hier zählt das Erscheinungsjahr. + const canonGroups = computed(() => { + const byGroup = new Map() + for (const movie of canonMovies.value) { + if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, []) + byGroup.get(movie.canon_group).push(movie) + } + return [...byGroup.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([group, parts]) => ({ group, ...sortiereReihe(parts) })) + }) + + // Im Netflix-Reiter bilden TMDBs Collections die Reihen + const netflixEntries = computed(() => { + const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id) + return [ + ...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })), + ...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })), + ] + }) + + return { + movies, + status, + loading, + error, + load, + netflixMovies, + canonMovies, + canonGroups, + netflixEntries, + SORTS, + } +} diff --git a/frontend/src/composables/useLangeListe.js b/frontend/src/composables/useLangeListe.js new file mode 100644 index 0000000..08702de --- /dev/null +++ b/frontend/src/composables/useLangeListe.js @@ -0,0 +1,81 @@ +// Eine Liste mit tausenden Karten: gerendert wird nur der Anfang, der Rest +// kommt beim Scrollen nach. Dazu der Knopf, der wieder nach oben bringt. +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' + +// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend +// Bilder — der Browser meldet dann "Seite reagiert nicht". +const STAPEL = 100 + +// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester +// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten. +const OBEN_DAUER = 450 + +export function useLangeListe({ liste, ansicht }) { + const gezeigt = ref(STAPEL) + const sichtbar = computed(() => liste.value.slice(0, gezeigt.value)) + + // Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an + watch([liste, ansicht], () => { + gezeigt.value = STAPEL + }) + + // Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein + // Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht. + const nachschub = ref(null) + const beobachter = new IntersectionObserver( + (eintraege) => { + if (eintraege.some((e) => e.isIntersecting)) mehrLaden() + }, + // Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet + { rootMargin: '600px' } + ) + + async function mehrLaden() { + if (gezeigt.value >= liste.value.length) return + gezeigt.value += STAPEL + // Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt + // der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —, + // käme sonst nie ein zweiter Stapel. + await nextTick() + const el = nachschub.value + if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden() + } + + // Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem + // Wechsel neu anmelden + watch(nachschub, (el, vorher) => { + if (vorher) beobachter.unobserve(vorher) + if (el) beobachter.observe(el) + }) + + // Bei 2500 Karten kommt man sonst nicht mehr nach oben + const obenKnopf = ref(false) + + function beimScrollen() { + obenKnopf.value = window.scrollY > 800 + } + + function nachOben() { + const start = window.scrollY + const beginn = performance.now() + + function schritt(jetzt) { + const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER) + // Sanft an- und auslaufend + const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2 + window.scrollTo(0, start * (1 - p)) + if (t < 1) requestAnimationFrame(schritt) + } + + requestAnimationFrame(schritt) + } + + onMounted(() => window.addEventListener('scroll', beimScrollen, { passive: true })) + + onUnmounted(() => { + window.removeEventListener('scroll', beimScrollen) + beobachter.disconnect() + }) + + return { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } +} diff --git a/frontend/src/composables/useMeins.js b/frontend/src/composables/useMeins.js new file mode 100644 index 0000000..4e92e05 --- /dev/null +++ b/frontend/src/composables/useMeins.js @@ -0,0 +1,124 @@ +// Wer ich bin und was an mir hängt: die Runde, meine Sammlung, mein +// Vorschlag, die gesehenen Filme und die Wünsche. Alles wird beim Start +// geladen und über den Live-Kanal frisch gehalten. +import { computed, onMounted, onUnmounted, ref } from 'vue' +import { abonnieren } from '../live' + +async function hole(url) { + try { + const resp = await fetch(url) + return resp.ok ? await resp.json() : null + } catch { + return null + } +} + +export function useMeins({ host }) { + // Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus. + // Den Namen setzt danach der Server an Vorschläge und Wertungen. + const runde = ref([]) + const ich = ref(null) + + async function ladeRunde() { + const daten = await hole('/api/users') + if (!daten) return + runde.value = daten.items + ich.value = daten.mine + } + + async function michSetzen(id) { + await fetch('/api/users/waehlen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: id ?? null }), + }) + // Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne + // Nachladen zeigt die Seite weiter die des vorigen Benutzers. + await ladeRunde() + ladeVorschlag() + ladeMerkliste() + ladeGesehen() + ladeWuensche() + } + + // Die geschauten Filme — die Karten und die Detailansicht zeigen damit an, + // ob ein Film schon lief + const gesehen = ref([]) + + async function ladeGesehen() { + gesehen.value = (await hole('/api/watched')) ?? gesehen.value + } + + // Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen + const gesehenIds = computed(() => new Set(gesehen.value.map((e) => e.movie_id))) + + // Ausgeblendete Filme zählen für Gäste nicht mit + const gesehenZahl = computed( + () => gesehen.value.filter((e) => host.value || !e.hidden).length + ) + + // Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen — + // und alle Filme auf der Bahn, damit die Karten es zeigen können + const meinVorschlag = ref(null) + const aufDemRad = ref([]) + + async function ladeVorschlag() { + const daten = await hole('/api/suggestions') + if (!daten) { + meinVorschlag.value = null + aufDemRad.value = [] + return + } + meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null + aufDemRad.value = daten.items.map((i) => i.movie_id) + } + + // Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad + const merkliste = ref([]) + + async function ladeMerkliste() { + merkliste.value = (await hole('/api/wishlist')) ?? merkliste.value + } + + // Was der Seite noch fehlt + const wuensche = ref([]) + + async function ladeWuensche() { + wuensche.value = (await hole('/api/features')) ?? wuensche.value + } + + let abbestellen = null + + onMounted(() => { + ladeVorschlag() + ladeGesehen() + ladeRunde() + ladeMerkliste() + ladeWuensche() + // Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen + abbestellen = abonnieren((nachricht) => { + if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag() + else if (nachricht.type === 'users') ladeRunde() + else if (nachricht.type === 'features') ladeWuensche() + }) + }) + + onUnmounted(() => abbestellen?.()) + + return { + runde, + ich, + michSetzen, + gesehen, + gesehenIds, + gesehenZahl, + ladeGesehen, + meinVorschlag, + aufDemRad, + ladeVorschlag, + merkliste, + ladeMerkliste, + wuensche, + ladeWuensche, + } +} diff --git a/frontend/src/composables/useZiehung.js b/frontend/src/composables/useZiehung.js new file mode 100644 index 0000000..1fb6974 --- /dev/null +++ b/frontend/src/composables/useZiehung.js @@ -0,0 +1,287 @@ +// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus Postern +// rast unter der Marke durch und bremst auf dem Gewinner aus. +// +// Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn rast los und kriecht am +// Ende). Jedes zweite Mal bricht danach die Halterung: kurze Ruhe, dann +// rollt die Bahn ein paar Poster zurück. Der Server nennt die Gesamtdauer +// und die Halte, die Phasenlängen stehen hier — Ruhe plus Rückfahrt müssen +// zu BRUCH_DAUER im Backend passen. +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { anlaufTon, fanfare, knacken, tick } from '../klang' + +const ANLAUF = 1.0 +const BRUCH_RUHE = 1.2 +const BRUCH_FAHRT = 2.5 +// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow +const ENTHUELLUNG = 0.6 + +// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen. +const KURVEN = { + fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)', + bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)', +} + +// Auf dem Handy sind die Poster kleiner, sonst passen keine drei ins Bild +const MASSE = { + breit: { kachel: 220, luecke: 10 }, + schmal: { kachel: 140, luecke: 8 }, +} +const SCHMAL = '(max-width: 640px)' + +// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst +// verschwimmt die Marke mit dem Rand +export const FARBEN = [ + '#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f', + '#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520', +] + +export function useZiehung({ items }) { + const schmal = ref(window.matchMedia(SCHMAL).matches) + const masse = computed(() => { + const m = schmal.value ? MASSE.schmal : MASSE.breit + return { ...m, schritt: m.kachel + m.luecke } + }) + + const ergebnis = ref(null) + const dreht = ref(false) + const gewinner = ref(null) + const dauer = ref(0) + const x = ref(0) + const anlauf = ref(false) + const bruch = ref(false) + const kurve = ref(KURVEN.fahrt) + const bahn = ref(null) + let timer = null + let enthuellung = null + + // Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die + // Zielkachel unter der Marke, egal wie breit das Fenster ist. + function stoppX(stopp) { + const { kachel, schritt } = masse.value + return -(stopp.position * schritt + kachel / 2 + stopp.versatz * kachel) + } + + // Vor der ersten Ziehung stehen die Vorschläge einfach in einer Reihe + function ruheX() { + const { luecke, schritt } = masse.value + return -(items.value.length * schritt - luecke) / 2 + } + + function fahren(sekunden, k, ziel) { + kurve.value = k + dauer.value = sekunden + x.value = ziel + } + + function ruhen() { + if (!ergebnis.value) fahren(0, KURVEN.fahrt, ruheX()) + } + + // Liest die laufende Position aus dem Übergang und tickt bei jedem + // Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames. + let zuletzt = -1 + let letzteX = 0 + let letzteZeit = 0 + + function ticken(zeit) { + if (!dreht.value || !bahn.value) return + const { luecke, schritt } = masse.value + const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform) + const idx = Math.floor((-m.m41 + luecke / 2) / schritt) + const dt = (zeit - letzteZeit) / 1000 + const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0 + letzteX = m.m41 + letzteZeit = zeit + if (zuletzt === -1) { + // Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren + zuletzt = idx + } else if (idx !== zuletzt) { + zuletzt = idx + tick(tempo) + } + requestAnimationFrame(ticken) + } + + function ticksStarten() { + zuletzt = -1 + letzteZeit = 0 + requestAnimationFrame(ticken) + } + + // Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch + // hat) und ein Ende. Wer später dazukommt, überspringt die vorderen. + function phasen(erg) { + const erst = erg.stopps[0] + const ende = erg.stopps.at(-1) + const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0 + const fahrt = erg.dauer - ANLAUF - extra + const liste = [ + { + d: ANLAUF, + start(rest) { + anlauf.value = true + if (rest > 0) anlaufTon(rest) + }, + ende() { + anlauf.value = false + }, + }, + { + d: fahrt, + start(rest) { + fahren(rest, KURVEN.fahrt, stoppX(erst)) + ticksStarten() + }, + }, + ] + if (erg.stopps.length > 1) { + liste.push( + { d: BRUCH_RUHE }, + { + d: BRUCH_FAHRT, + start(rest) { + bruch.value = true + if (rest > 0) knacken() + fahren(rest, KURVEN.bruch, stoppX(ende)) + }, + ende() { + bruch.value = false + }, + } + ) + } + return liste + } + + function ablauf(erg, liste, i, uebersprungen) { + if (i >= liste.length) { + dreht.value = false + // Erst Stille, dann der Glow + enthuellung = window.setTimeout(() => { + gewinner.value = erg.gewinner + fanfare() + }, ENTHUELLUNG * 1000) + return + } + const ph = liste[i] + if (uebersprungen >= ph.d) { + // Schon vorbei: nur den Endzustand setzen + ph.start?.(0) + ph.ende?.() + ablauf(erg, liste, i + 1, uebersprungen - ph.d) + return + } + const rest = ph.d - uebersprungen + ph.start?.(rest) + timer = window.setTimeout(() => { + ph.ende?.() + ablauf(erg, liste, i + 1, 0) + }, rest * 1000) + } + + function abbrechen() { + clearTimeout(timer) + clearTimeout(enthuellung) + } + + async function abspielen(erg, uebersprungen = 0) { + abbrechen() + gewinner.value = null + ergebnis.value = erg + dreht.value = true + + // Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten + // Ergebnis aus los + fahren(0, KURVEN.fahrt, -3 * masse.value.schritt) + await nextTick() + void bahn.value?.offsetWidth + + ablauf(erg, phasen(erg), 0, uebersprungen) + } + + async function laufendenDrehHolen() { + try { + const resp = await fetch('/api/spin') + if (!resp.ok) return + const daten = await resp.json() + if (!daten.result) return + if (daten.running) { + abspielen(daten.result, daten.elapsed) + } else { + // Ziehung ist durch: Bahn ohne Animation auf die Endstellung setzen, + // sonst steht die Marke woanders als die Gewinnerkarte + ergebnis.value = daten.result + fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1))) + gewinner.value = daten.result.gewinner + } + } catch { + /* dann eben ohne */ + } + } + + function zurueckgesetzt() { + abbrechen() + anlauf.value = false + bruch.value = false + ergebnis.value = null + gewinner.value = null + dreht.value = false + } + + // Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge + const kacheln = computed(() => { + const erg = ergebnis.value + if (erg) { + const ziel = erg.stopps.at(-1).position + return erg.streifen.map((i, k) => ({ + key: k, + film: erg.karten[i], + farbe: FARBEN[i % FARBEN.length], + treffer: k === ziel, + })) + } + return items.value.map((item, i) => ({ + key: i, + film: item, + farbe: FARBEN[i % FARBEN.length], + treffer: false, + })) + }) + + // Dreht jemand das Handy, ändern sich die Maße — die Bahn rückt nach + const medien = window.matchMedia(SCHMAL) + const beiWechsel = (e) => { + schmal.value = e.matches + } + + watch(schmal, () => { + if (dreht.value) return + if (ergebnis.value) fahren(0, KURVEN.fahrt, stoppX(ergebnis.value.stopps.at(-1))) + else ruhen() + }) + + onMounted(() => medien.addEventListener('change', beiWechsel)) + + onUnmounted(() => { + abbrechen() + medien.removeEventListener('change', beiWechsel) + }) + + return { + masse, + ergebnis, + dreht, + gewinner, + dauer, + x, + anlauf, + bruch, + kurve, + bahn, + kacheln, + ruhen, + abspielen, + laufendenDrehHolen, + zurueckgesetzt, + } +} diff --git a/frontend/src/klang.js b/frontend/src/klang.js new file mode 100644 index 0000000..862bb46 --- /dev/null +++ b/frontend/src/klang.js @@ -0,0 +1,50 @@ +// Die Geräusche der Ziehung: ein Tick je Poster, das unter der Marke +// durchläuft, ein Aufladen im Anlauf, ein Knacken beim Bruch und ein Klang +// zur Enthüllung. Alles synthetisch, keine Dateien. +// +// Der Browser gibt Ton erst nach einer Eingabe frei — wer nur zuschaut und +// nie geklickt hat, hört nichts, sieht aber alles. + +let audio = null + +export function klang() { + if (!audio) audio = new AudioContext() + if (audio.state === 'suspended') audio.resume() + return audio +} + +function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) { + const a = klang() + const t = a.currentTime + start + const osc = a.createOscillator() + const gain = a.createGain() + osc.type = art + osc.frequency.value = frequenz + if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer) + gain.gain.setValueAtTime(lautstaerke, t) + gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer) + osc.connect(gain).connect(a.destination) + osc.start(t) + osc.stop(t + dauer) +} + +// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger — +// man hört das Bremsen. +export function tick(tempo) { + const a = Math.min(1, tempo / 3000) + ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a) +} + +export function anlaufTon(sekunden) { + ton(150, sekunden, 0.08, 'sine', 0, 520) +} + +export function knacken() { + ton(120, 0.12, 0.25, 'square') + ton(80, 0.3, 0.2, 'sawtooth', 0.08) +} + +export function fanfare() { + ton(660, 0.5, 0.12, 'sine') + ton(990, 0.7, 0.1, 'sine', 0.12) +}