update
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@ node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
backend/static/
|
backend/static/
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
backend/backups/
|
||||||
|
data/backups/
|
||||||
|
|||||||
5
Makefile
5
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:
|
install:
|
||||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
cd frontend && npm install
|
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
|
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
|
||||||
# Leere und OBS findet keinen Server.
|
# Leere und OBS findet keinen Server.
|
||||||
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst
|
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst
|
||||||
|
|||||||
39
backend/app/backup.py
Normal file
39
backend/app/backup.py
Normal file
@@ -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
|
||||||
@@ -10,6 +10,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
backup,
|
||||||
db,
|
db,
|
||||||
events,
|
events,
|
||||||
features,
|
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():
|
async def guarded_sync():
|
||||||
if sync_lock.locked():
|
if sync_lock.locked():
|
||||||
log.info("Sync läuft bereits")
|
log.info("Sync läuft bereits")
|
||||||
@@ -85,6 +91,9 @@ async def startup():
|
|||||||
watched.init()
|
watched.init()
|
||||||
wishlist.init()
|
wishlist.init()
|
||||||
features.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.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
if not db.get_meta("last_sync"):
|
if not db.get_meta("last_sync"):
|
||||||
|
|||||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
1
backend/requirements-dev.txt
Normal file
1
backend/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pytest==8.4.2
|
||||||
91
backend/tests/conftest.py
Normal file
91
backend/tests/conftest.py
Normal file
@@ -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
|
||||||
74
backend/tests/test_features.py
Normal file
74
backend/tests/test_features.py
Normal file
@@ -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
|
||||||
6
backend/tests/test_start.py
Normal file
6
backend/tests/test_start.py
Normal file
@@ -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 == []
|
||||||
94
backend/tests/test_suggestions.py
Normal file
94
backend/tests/test_suggestions.py
Normal file
@@ -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"] == []
|
||||||
46
backend/tests/test_users.py
Normal file
46
backend/tests/test_users.py
Normal file
@@ -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
|
||||||
63
backend/tests/test_watched.py
Normal file
63
backend/tests/test_watched.py
Normal file
@@ -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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { abonnieren } from './live'
|
|
||||||
import MovieCard from './components/MovieCard.vue'
|
import MovieCard from './components/MovieCard.vue'
|
||||||
import FilterSidebar from './components/FilterSidebar.vue'
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
@@ -14,507 +13,31 @@ import StreamTab from './components/StreamTab.vue'
|
|||||||
import WatchedTab from './components/WatchedTab.vue'
|
import WatchedTab from './components/WatchedTab.vue'
|
||||||
import WheelTab from './components/WheelTab.vue'
|
import WheelTab from './components/WheelTab.vue'
|
||||||
import SeriesCard from './components/SeriesCard.vue'
|
import SeriesCard from './components/SeriesCard.vue'
|
||||||
|
import { useEinstellungen } from './composables/useEinstellungen'
|
||||||
|
import { useMeins } from './composables/useMeins'
|
||||||
|
import { matches, useKatalog } from './composables/useKatalog'
|
||||||
|
import { REGLER, useFilter } from './composables/useFilter'
|
||||||
|
import { useLangeListe } from './composables/useLangeListe'
|
||||||
|
|
||||||
const movies = ref([])
|
const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen()
|
||||||
const status = ref(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref('')
|
|
||||||
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')
|
|
||||||
|
|
||||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
const {
|
||||||
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
runde, ich, michSetzen,
|
||||||
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
|
||||||
|
meinVorschlag, aufDemRad, ladeVorschlag,
|
||||||
|
merkliste, ladeMerkliste,
|
||||||
|
wuensche, ladeWuensche,
|
||||||
|
} = useMeins({ host })
|
||||||
|
|
||||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
const {
|
||||||
|
movies, loading, error, load,
|
||||||
|
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
||||||
|
} = useKatalog()
|
||||||
|
|
||||||
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
|
const {
|
||||||
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
|
||||||
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene'
|
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
|
||||||
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus')
|
} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
||||||
|
|
||||||
watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus'))
|
|
||||||
const selected = ref(null)
|
|
||||||
|
|
||||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
|
||||||
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
|
||||||
// Bilder kommen gleich danach.
|
|
||||||
//
|
|
||||||
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
|
||||||
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
|
||||||
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
|
||||||
async function filmOeffnen(movie) {
|
|
||||||
selected.value = movie
|
|
||||||
if (!movie?.id) return
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/search/${movie.id}`)
|
|
||||||
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
|
||||||
} catch {
|
|
||||||
/* dann eben mit den Listendaten */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
|
||||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
|
||||||
const TAB_IDS = [
|
|
||||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
|
||||||
'wuensche', 'verwaltung', 'stream',
|
|
||||||
]
|
|
||||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
|
||||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
|
||||||
|
|
||||||
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
|
|
||||||
const include = ref([])
|
|
||||||
const exclude = ref([])
|
|
||||||
|
|
||||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
|
||||||
const canonMovies = computed(() =>
|
|
||||||
movies.value.filter((m) => m.canon_group !== null)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
|
||||||
// sonst wandern die Enden bei jeder Auswahl
|
|
||||||
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 }))
|
|
||||||
|
|
||||||
const grenzen = computed(() => {
|
|
||||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
|
|
||||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
|
||||||
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({})
|
|
||||||
|
|
||||||
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
|
|
||||||
watch(
|
|
||||||
grenzen,
|
|
||||||
(neu) => {
|
|
||||||
ranges.value = Object.fromEntries(
|
|
||||||
Object.entries(neu).map(([k, v]) => [k, [v.min, v.max]])
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetRanges() {
|
|
||||||
ranges.value = Object.fromEntries(
|
|
||||||
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
|
||||||
const genreOptions = computed(() => {
|
|
||||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
|
|
||||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
|
||||||
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 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
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 })),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabs = computed(() => [
|
|
||||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
|
||||||
{
|
|
||||||
id: 'canon',
|
|
||||||
label: 'Horror-Kanon',
|
|
||||||
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
|
||||||
},
|
|
||||||
{ id: 'search', label: 'Suche', count: null },
|
|
||||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
|
||||||
...(host.value
|
|
||||||
? []
|
|
||||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
|
||||||
{ id: 'wheel', label: 'Rad', count: null },
|
|
||||||
{ id: 'stream', label: 'Stream', count: null },
|
|
||||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
|
||||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
|
||||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
|
||||||
const aehnlichStart = ref(null)
|
|
||||||
|
|
||||||
function zurAehnlichkeitsuche(movie) {
|
|
||||||
aehnlichStart.value = movie
|
|
||||||
selected.value = null
|
|
||||||
activeTab.value = 'aehnlich'
|
|
||||||
}
|
|
||||||
|
|
||||||
const rad = ref(null)
|
|
||||||
|
|
||||||
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
|
||||||
const obenKnopf = ref(false)
|
|
||||||
|
|
||||||
function beimScrollen() {
|
|
||||||
obenKnopf.value = window.scrollY > 800
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
|
|
||||||
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
|
|
||||||
const OBEN_DAUER = 450
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
|
|
||||||
// ob der Film schon lief
|
|
||||||
const gesehen = ref([])
|
|
||||||
|
|
||||||
async function ladeGesehen() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/watched')
|
|
||||||
if (resp.ok) gesehen.value = await resp.json()
|
|
||||||
} catch {
|
|
||||||
/* dann eben ohne */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 den Abend leitet, darf drehen und die Runde leeren. Kein Login —
|
|
||||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
|
||||||
// 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 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ladeRunde() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/users')
|
|
||||||
if (!resp.ok) return
|
|
||||||
const daten = await resp.json()
|
|
||||||
runde.value = daten.items
|
|
||||||
ich.value = daten.mine
|
|
||||||
} catch {
|
|
||||||
/* dann eben ohne */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOST_SCHLUESSEL = 'filmabend_host'
|
|
||||||
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '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) => {
|
|
||||||
// Verwaltung sieht nur der Host, Vorschläge nur die Gäste
|
|
||||||
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(HOST_SCHLUESSEL, host.value ? '1' : '0')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
|
||||||
const meinVorschlag = ref(null)
|
|
||||||
|
|
||||||
// Alle Filme auf dem Rad, damit die Karten es zeigen können
|
|
||||||
const aufDemRad = ref([])
|
|
||||||
|
|
||||||
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
|
|
||||||
const merkliste = ref([])
|
|
||||||
|
|
||||||
// Was der Seite noch fehlt
|
|
||||||
const wuensche = ref([])
|
|
||||||
|
|
||||||
async function ladeWuensche() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/features')
|
|
||||||
if (resp.ok) wuensche.value = await resp.json()
|
|
||||||
} catch {
|
|
||||||
/* dann eben ohne */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ladeMerkliste() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/wishlist')
|
|
||||||
if (resp.ok) merkliste.value = await resp.json()
|
|
||||||
} catch {
|
|
||||||
/* dann eben ohne */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ladeVorschlag() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/suggestions')
|
|
||||||
if (!resp.ok) return
|
|
||||||
const daten = await resp.json()
|
|
||||||
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
|
|
||||||
aufDemRad.value = daten.items.map((i) => i.movie_id)
|
|
||||||
} catch {
|
|
||||||
meinVorschlag.value = null
|
|
||||||
aufDemRad.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nachVorschlag() {
|
|
||||||
ladeVorschlag()
|
|
||||||
rad.value?.neu()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 matches(movie, term) {
|
|
||||||
return (
|
|
||||||
movie.title.toLowerCase().includes(term) ||
|
|
||||||
(movie.original_title || '').toLowerCase().includes(term)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 nichtGesehen(movie) {
|
|
||||||
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function passt(movie) {
|
|
||||||
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
||||||
// ihren stärksten Teil
|
// ihren stärksten Teil
|
||||||
@@ -538,55 +61,58 @@ const visibleCanon = computed(() =>
|
|||||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gefiltert wird über den ganzen Katalog, gerendert nur der Anfang davon.
|
|
||||||
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
|
|
||||||
// Bilder — der Browser meldet dann "Seite reagiert nicht".
|
|
||||||
const STAPEL = 100
|
|
||||||
const gezeigt = ref(STAPEL)
|
|
||||||
|
|
||||||
const aktuelleListe = computed(() =>
|
const aktuelleListe = computed(() =>
|
||||||
activeTab.value === 'netflix' ? visible.value : visibleCanon.value
|
activeTab.value === 'netflix' ? visible.value : visibleCanon.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const sichtbar = computed(() => aktuelleListe.value.slice(0, gezeigt.value))
|
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
|
||||||
|
liste: aktuelleListe,
|
||||||
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
|
ansicht,
|
||||||
watch([aktuelleListe, ansicht], () => {
|
|
||||||
gezeigt.value = STAPEL
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mehrLaden() {
|
const tabs = computed(() => [
|
||||||
if (gezeigt.value >= aktuelleListe.value.length) return
|
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
||||||
gezeigt.value += STAPEL
|
{
|
||||||
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
|
id: 'canon',
|
||||||
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
|
label: 'Horror-Kanon',
|
||||||
// käme sonst nie ein zweiter Stapel.
|
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
||||||
await nextTick()
|
|
||||||
const el = nachschub.value
|
|
||||||
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
|
|
||||||
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
|
|
||||||
// Gleich hier angelegt, nicht in onMounted: die Vorlage setzt die Referenz
|
|
||||||
// schon beim ersten Zeichnen, da muss der Beobachter bereits stehen.
|
|
||||||
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
|
{ id: 'search', label: 'Suche', count: null },
|
||||||
{ rootMargin: '600px' }
|
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||||
|
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||||
|
{ id: 'wheel', label: 'Rad', count: null },
|
||||||
|
{ id: 'stream', label: 'Stream', count: null },
|
||||||
|
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||||
|
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
||||||
|
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
|
||||||
|
// klappt sie über einen Knopf auf
|
||||||
|
const filterOffen = ref(false)
|
||||||
|
const mitFilter = computed(() =>
|
||||||
|
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
onUnmounted(() => beobachter.disconnect())
|
const selected = ref(null)
|
||||||
|
|
||||||
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
|
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||||
// Wechsel neu anmelden
|
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
||||||
watch(nachschub, (el, vorher) => {
|
// Bilder kommen gleich danach.
|
||||||
if (vorher) beobachter.unobserve(vorher)
|
//
|
||||||
if (el) beobachter.observe(el)
|
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
||||||
})
|
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
||||||
|
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
||||||
|
async function filmOeffnen(movie) {
|
||||||
|
selected.value = movie
|
||||||
|
if (!movie?.id) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann eben mit den Listendaten */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
||||||
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
||||||
@@ -607,60 +133,27 @@ const series = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentCount = computed(() =>
|
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
const aehnlichStart = ref(null)
|
||||||
)
|
|
||||||
|
|
||||||
async function load() {
|
function zurAehnlichkeitsuche(movie) {
|
||||||
loading.value = true
|
aehnlichStart.value = movie
|
||||||
error.value = ''
|
selected.value = null
|
||||||
try {
|
activeTab.value = 'aehnlich'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let abbestellen = null
|
const rad = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
function nachVorschlag() {
|
||||||
load()
|
|
||||||
ladeVorschlag()
|
ladeVorschlag()
|
||||||
ladeGesehen()
|
rad.value?.neu()
|
||||||
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()
|
|
||||||
})
|
|
||||||
window.addEventListener('keydown', tastatur)
|
|
||||||
window.addEventListener('scroll', beimScrollen, { passive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onMounted(load)
|
||||||
window.removeEventListener('keydown', tastatur)
|
|
||||||
window.removeEventListener('scroll', beimScrollen)
|
|
||||||
abbestellen?.()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page" :class="{
|
<div class="page" :class="{ breit: !mitFilter }">
|
||||||
breit: [
|
|
||||||
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
|
||||||
].includes(activeTab),
|
|
||||||
}">
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -672,6 +165,15 @@ onUnmounted(() => {
|
|||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mitFilter"
|
||||||
|
type="button"
|
||||||
|
class="filter-knopf"
|
||||||
|
:class="{ an: filterOffen }"
|
||||||
|
@click="filterOffen = !filterOffen"
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
|
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
|
||||||
<span v-else class="host">Host</span>
|
<span v-else class="host">Host</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -687,8 +189,8 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AehnlichTab
|
<AehnlichTab
|
||||||
:gesehen-ids="gesehenIds"
|
|
||||||
v-else
|
v-else
|
||||||
|
:gesehen-ids="gesehenIds"
|
||||||
:filter="suchFilter"
|
:filter="suchFilter"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
:start="aehnlichStart"
|
:start="aehnlichStart"
|
||||||
@@ -701,6 +203,7 @@ onUnmounted(() => {
|
|||||||
v-model:ansicht="ansicht"
|
v-model:ansicht="ansicht"
|
||||||
v-model:ohne-gesehene="ohneGesehene"
|
v-model:ohne-gesehene="ohneGesehene"
|
||||||
v-model:ranges="ranges"
|
v-model:ranges="ranges"
|
||||||
|
:class="{ offen: filterOffen }"
|
||||||
:regler="REGLER"
|
:regler="REGLER"
|
||||||
:grenzen="grenzen"
|
:grenzen="grenzen"
|
||||||
:options="genreOptions"
|
:options="genreOptions"
|
||||||
@@ -755,40 +258,40 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-else class="layout">
|
<div v-else class="layout">
|
||||||
<div class="results">
|
<div class="results">
|
||||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||||
<p v-else-if="!movies.length" class="note">
|
<p v-else-if="!movies.length" class="note">
|
||||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="!currentCount" class="note">
|
<p v-else-if="!aktuelleListe.length" class="note">
|
||||||
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid" :class="ansicht">
|
<div class="grid" :class="ansicht">
|
||||||
<template v-for="entry in sichtbar" :key="entry.group">
|
<template v-for="entry in sichtbar" :key="entry.group">
|
||||||
<MovieCard
|
<MovieCard
|
||||||
v-if="entry.parts.length === 1"
|
v-if="entry.parts.length === 1"
|
||||||
:movie="entry.parts[0]"
|
:movie="entry.parts[0]"
|
||||||
:gesehen="gesehenIds.has(entry.parts[0].id)"
|
:gesehen="gesehenIds.has(entry.parts[0].id)"
|
||||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
@open="filmOeffnen"
|
@open="filmOeffnen"
|
||||||
/>
|
/>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
v-else
|
v-else
|
||||||
:parts="entry.parts"
|
:parts="entry.parts"
|
||||||
:gesehen-ids="gesehenIds"
|
:gesehen-ids="gesehenIds"
|
||||||
:start="entry.best"
|
:start="entry.best"
|
||||||
:auf-dem-rad="aufDemRad"
|
:auf-dem-rad="aufDemRad"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
@open="filmOeffnen"
|
@open="filmOeffnen"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
|
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
|
||||||
Lädt weitere Filme …
|
Lädt weitere Filme …
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
@@ -798,6 +301,7 @@ onUnmounted(() => {
|
|||||||
v-model:ansicht="ansicht"
|
v-model:ansicht="ansicht"
|
||||||
v-model:ohne-gesehene="ohneGesehene"
|
v-model:ohne-gesehene="ohneGesehene"
|
||||||
v-model:ranges="ranges"
|
v-model:ranges="ranges"
|
||||||
|
:class="{ offen: filterOffen }"
|
||||||
:regler="REGLER"
|
:regler="REGLER"
|
||||||
:grenzen="grenzen"
|
:grenzen="grenzen"
|
||||||
:options="genreOptions"
|
:options="genreOptions"
|
||||||
@@ -817,18 +321,17 @@ onUnmounted(() => {
|
|||||||
v-if="selected"
|
v-if="selected"
|
||||||
:movie="selected"
|
:movie="selected"
|
||||||
:series="series"
|
:series="series"
|
||||||
@close="selected = null"
|
|
||||||
@open="filmOeffnen"
|
|
||||||
:mein-vorschlag="meinVorschlag"
|
:mein-vorschlag="meinVorschlag"
|
||||||
:host="host"
|
:host="host"
|
||||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||||
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
|
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
|
||||||
|
@close="selected = null"
|
||||||
|
@open="filmOeffnen"
|
||||||
@gemerkt="ladeMerkliste"
|
@gemerkt="ladeMerkliste"
|
||||||
@suggested="nachVorschlag"
|
@suggested="nachVorschlag"
|
||||||
@watched="ladeGesehen"
|
@watched="ladeGesehen"
|
||||||
@aehnlich="zurAehnlichkeitsuche"
|
@aehnlich="zurAehnlichkeitsuche"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -839,7 +342,7 @@ onUnmounted(() => {
|
|||||||
padding-right: calc(var(--sidebar) + 1.25rem);
|
padding-right: calc(var(--sidebar) + 1.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Such- und Rad-Reiter haben keine Filterleiste */
|
/* Reiter ohne Filterleiste */
|
||||||
.page.breit {
|
.page.breit {
|
||||||
padding-right: 1.25rem;
|
padding-right: 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -886,6 +389,7 @@ onUnmounted(() => {
|
|||||||
padding: 0.8rem 1rem 0.6rem;
|
padding: 0.8rem 1rem 0.6rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,6 +402,12 @@ onUnmounted(() => {
|
|||||||
border-bottom-color: var(--accent);
|
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 {
|
.host {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
@@ -938,26 +448,6 @@ onUnmounted(() => {
|
|||||||
border-left: 1px solid var(--line);
|
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 {
|
.nachschub {
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -990,4 +480,82 @@ onUnmounted(() => {
|
|||||||
color: var(--accent);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -258,4 +258,21 @@ h2 {
|
|||||||
.card.details .shots {
|
.card.details .shots {
|
||||||
overflow: hidden;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -701,6 +701,17 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
.backdrop {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -712,6 +723,10 @@ h3 {
|
|||||||
.shots {
|
.shots {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lupe {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot {
|
.shot {
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ async function open(movie) {
|
|||||||
|
|
||||||
.big-search {
|
.big-search {
|
||||||
flex: 1;
|
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;
|
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,7 @@ async function open(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.neu {
|
.neu {
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0 1.4rem;
|
padding: 0 1.4rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -360,4 +360,25 @@ h2:hover {
|
|||||||
.card.details .shots {
|
.card.details .shots {
|
||||||
overflow: hidden;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.rahmen {
|
.rahmen {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
|
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
|
||||||
max-height: calc(100vh - 5.5rem);
|
max-height: calc(100vh - 5.5rem);
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
|
|||||||
264
frontend/src/components/VorschlagsListe.vue
Normal file
264
frontend/src/components/VorschlagsListe.vue
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { posterUrl } from '../images'
|
||||||
|
import { FARBEN } from '../composables/useZiehung'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
laden: Boolean,
|
||||||
|
host: Boolean,
|
||||||
|
merkliste: { type: Array, default: () => [] },
|
||||||
|
mine: { type: Number, default: null },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['open', 'fehler'])
|
||||||
|
|
||||||
|
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
||||||
|
// anderen — für Abende, an denen es keiner gruselig mag.
|
||||||
|
const nurHorror = ref(true)
|
||||||
|
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
|
||||||
|
const wuerfelt = ref(false)
|
||||||
|
|
||||||
|
function nachGenre(horror) {
|
||||||
|
return sammlung.value.filter(
|
||||||
|
(e) => (e.genres || []).includes('Horror') === horror
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const auswahl = computed(() => nachGenre(nurHorror.value))
|
||||||
|
const anzahlHorror = computed(() => nachGenre(true).length)
|
||||||
|
const anzahlOhne = computed(() => nachGenre(false).length)
|
||||||
|
|
||||||
|
async function wuerfeln() {
|
||||||
|
const treffer = auswahl.value
|
||||||
|
if (!treffer.length || wuerfelt.value) return
|
||||||
|
wuerfelt.value = true
|
||||||
|
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ movie_id: film.movie_id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error()
|
||||||
|
} catch {
|
||||||
|
emit('fehler', 'Eintragen hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
wuerfelt.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="liste">
|
||||||
|
<div v-if="!host" class="sammlung-wuerfeln">
|
||||||
|
<div class="schalter">
|
||||||
|
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
||||||
|
Horror ({{ anzahlHorror }})
|
||||||
|
</button>
|
||||||
|
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
||||||
|
Kein Horror ({{ anzahlOhne }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wuerfeln"
|
||||||
|
:disabled="!auswahl.length || wuerfelt"
|
||||||
|
@click="wuerfeln"
|
||||||
|
>
|
||||||
|
Zufällig aus Sammlung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||||
|
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(item, i) in items" :key="item.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="eintrag"
|
||||||
|
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
|
||||||
|
@click="emit('open', item)"
|
||||||
|
>
|
||||||
|
<span class="cover" :style="{ borderColor: FARBEN[i % FARBEN.length] }">
|
||||||
|
<img
|
||||||
|
v-if="posterUrl(item.poster_path, 'w154')"
|
||||||
|
:src="posterUrl(item.poster_path, 'w154')"
|
||||||
|
:alt="item.title"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="daten">
|
||||||
|
<span class="titel">{{ item.title }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="item.genres?.length" class="genres">
|
||||||
|
{{ item.genres.join(' · ') }}
|
||||||
|
</span>
|
||||||
|
<span class="von">{{ item.name || 'Anonym' }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leer {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -369,7 +369,7 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||||
gap: 1.1rem;
|
gap: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,4 +716,14 @@ h3:hover {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.rezension-hintergrund {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rezension {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import { abonnieren } from '../live'
|
import { abonnieren } from '../live'
|
||||||
|
import { klang } from '../klang'
|
||||||
|
import { useZiehung } from '../composables/useZiehung'
|
||||||
|
import VorschlagsListe from './VorschlagsListe.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
host: Boolean,
|
host: Boolean,
|
||||||
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
||||||
merkliste: { type: Array, default: () => [] },
|
merkliste: { type: Array, default: () => [] },
|
||||||
@@ -16,118 +19,10 @@ const items = ref([])
|
|||||||
const laden = ref(true)
|
const laden = ref(true)
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
|
|
||||||
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
|
const {
|
||||||
// Postern rast unter der Marke durch und bremst auf dem Gewinner aus.
|
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
|
||||||
const KACHEL = 220
|
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
|
||||||
const LUECKE = 10
|
} = useZiehung({ items })
|
||||||
const SCHRITT = KACHEL + LUECKE
|
|
||||||
// Die Ziehung läuft in 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.
|
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
|
||||||
// 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)',
|
|
||||||
}
|
|
||||||
const kurve = ref(KURVEN.fahrt)
|
|
||||||
const bahn = ref(null)
|
|
||||||
let timer = null
|
|
||||||
let enthuellung = null
|
|
||||||
|
|
||||||
// ── Ton ─────────────────────────────────────────────────────────────
|
|
||||||
// Ein Tick je Poster, das unter der Marke durchläuft, 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
function anlaufTon(sekunden) {
|
|
||||||
ton(150, sekunden, 0.08, 'sine', 0, 520)
|
|
||||||
}
|
|
||||||
|
|
||||||
function knacken() {
|
|
||||||
ton(120, 0.12, 0.25, 'square')
|
|
||||||
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fanfare() {
|
|
||||||
ton(660, 0.5, 0.12, 'sine')
|
|
||||||
ton(990, 0.7, 0.1, 'sine', 0.12)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
|
||||||
// verschwimmt die Marke mit dem Rand
|
|
||||||
const FARBEN = [
|
|
||||||
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
|
||||||
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||||
let abbestellen = null
|
let abbestellen = null
|
||||||
@@ -136,138 +31,12 @@ function verbinden() {
|
|||||||
abbestellen = abonnieren((nachricht) => {
|
abbestellen = abonnieren((nachricht) => {
|
||||||
if (nachricht.type === 'suggestions') laden_()
|
if (nachricht.type === 'suggestions') laden_()
|
||||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||||
else if (nachricht.type === 'reset') zurueckgesetzt()
|
else if (nachricht.type === 'reset') {
|
||||||
})
|
zurueckgesetzt()
|
||||||
}
|
emit('reset')
|
||||||
|
laden_()
|
||||||
// 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) {
|
|
||||||
return -(stopp.position * SCHRITT + KACHEL / 2 + stopp.versatz * KACHEL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vor dem ersten Dreh stehen die Vorschläge einfach in einer Reihe
|
|
||||||
function ruheX() {
|
|
||||||
return -(items.value.length * SCHRITT - LUECKE) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function fahren(sekunden, k, ziel) {
|
|
||||||
kurve.value = k
|
|
||||||
dauer.value = sekunden
|
|
||||||
x.value = ziel
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function abspielen(erg, uebersprungen = 0) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
clearTimeout(enthuellung)
|
|
||||||
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 * 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 {
|
|
||||||
// Dreh 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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function laden_() {
|
async function laden_() {
|
||||||
@@ -278,10 +47,7 @@ async function laden_() {
|
|||||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||||
const daten = await resp.json()
|
const daten = await resp.json()
|
||||||
items.value = daten.items
|
items.value = daten.items
|
||||||
if (!ergebnis.value) {
|
ruhen()
|
||||||
dauer.value = 0
|
|
||||||
x.value = ruheX()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fehler.value = err.message
|
fehler.value = err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -289,44 +55,11 @@ async function laden_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await laden_()
|
|
||||||
await laufendenDrehHolen()
|
|
||||||
verbinden()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
clearTimeout(enthuellung)
|
|
||||||
abbestellen?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({ neu: laden_ })
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
async function drehen() {
|
async function drehen() {
|
||||||
if (dreht.value || !items.value.length) return
|
if (dreht.value || !items.value.length) return
|
||||||
|
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
|
||||||
|
// aus und schickt das Ergebnis an alle, auch an uns.
|
||||||
klang()
|
klang()
|
||||||
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/spin', { method: 'POST' })
|
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||||
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
|
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
|
||||||
@@ -335,18 +68,6 @@ async function drehen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zurueckgesetzt() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
clearTimeout(enthuellung)
|
|
||||||
anlauf.value = false
|
|
||||||
bruch.value = false
|
|
||||||
ergebnis.value = null
|
|
||||||
gewinner.value = null
|
|
||||||
dreht.value = false
|
|
||||||
emit('reset')
|
|
||||||
laden_()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function zuruecksetzen() {
|
async function zuruecksetzen() {
|
||||||
// Der Server meldet den Reset an alle zurück, auch an uns
|
// Der Server meldet den Reset an alle zurück, auch an uns
|
||||||
try {
|
try {
|
||||||
@@ -356,45 +77,20 @@ async function zuruecksetzen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
|
||||||
// anderen — für Abende, an denen es keiner gruselig mag.
|
|
||||||
const nurHorror = ref(true)
|
|
||||||
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
|
|
||||||
const wuerfelt = ref(false)
|
|
||||||
|
|
||||||
function nachGenre(horror) {
|
|
||||||
return sammlung.value.filter(
|
|
||||||
(e) => (e.genres || []).includes('Horror') === horror
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const auswahl = computed(() => nachGenre(nurHorror.value))
|
|
||||||
const anzahlHorror = computed(() => nachGenre(true).length)
|
|
||||||
const anzahlOhne = computed(() => nachGenre(false).length)
|
|
||||||
|
|
||||||
async function wuerfeln() {
|
|
||||||
const treffer = auswahl.value
|
|
||||||
if (!treffer.length || wuerfelt.value) return
|
|
||||||
wuerfelt.value = true
|
|
||||||
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/suggestions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ movie_id: film.movie_id }),
|
|
||||||
})
|
|
||||||
if (!resp.ok) throw new Error()
|
|
||||||
} catch {
|
|
||||||
fehler.value = 'Eintragen hat nicht geklappt.'
|
|
||||||
} finally {
|
|
||||||
wuerfelt.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function oeffnen(item) {
|
async function oeffnen(item) {
|
||||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||||
if (resp.ok) emit('open', await resp.json())
|
if (resp.ok) emit('open', await resp.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await laden_()
|
||||||
|
await laufendenDrehHolen()
|
||||||
|
verbinden()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => abbestellen?.())
|
||||||
|
|
||||||
|
defineExpose({ neu: laden_ })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -402,7 +98,10 @@ async function oeffnen(item) {
|
|||||||
<div class="buehne">
|
<div class="buehne">
|
||||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
|
||||||
<div class="fenster">
|
<div
|
||||||
|
class="fenster"
|
||||||
|
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
|
||||||
|
>
|
||||||
<div class="marke" :class="{ anlauf, bruch }"></div>
|
<div class="marke" :class="{ anlauf, bruch }"></div>
|
||||||
<div
|
<div
|
||||||
ref="bahn"
|
ref="bahn"
|
||||||
@@ -468,61 +167,16 @@ async function oeffnen(item) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="liste">
|
<VorschlagsListe
|
||||||
<div v-if="!host" class="sammlung-wuerfeln">
|
:items="items"
|
||||||
<div class="schalter">
|
:laden="laden"
|
||||||
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
:host="host"
|
||||||
Horror ({{ anzahlHorror }})
|
:merkliste="merkliste"
|
||||||
</button>
|
:mine="mine"
|
||||||
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
:gesehen-ids="gesehenIds"
|
||||||
Kein Horror ({{ anzahlOhne }})
|
@open="oeffnen"
|
||||||
</button>
|
@fehler="fehler = $event"
|
||||||
</div>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wuerfeln"
|
|
||||||
:disabled="!auswahl.length || wuerfelt"
|
|
||||||
@click="wuerfeln"
|
|
||||||
>
|
|
||||||
Zufällig aus Sammlung
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
|
||||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(item, i) in items" :key="item.id">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="eintrag"
|
|
||||||
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
|
|
||||||
@click="oeffnen(item)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="cover"
|
|
||||||
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="posterUrl(item.poster_path, 'w154')"
|
|
||||||
:src="posterUrl(item.poster_path, 'w154')"
|
|
||||||
:alt="item.title"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span class="daten">
|
|
||||||
<span class="titel">{{ item.title }}</span>
|
|
||||||
<span class="meta">
|
|
||||||
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
|
||||||
</span>
|
|
||||||
<span v-if="item.genres?.length" class="genres">
|
|
||||||
{{ item.genres.join(' · ') }}
|
|
||||||
</span>
|
|
||||||
<span class="von">{{ item.name || 'Anonym' }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -546,7 +200,8 @@ async function oeffnen(item) {
|
|||||||
.fenster {
|
.fenster {
|
||||||
position: relative;
|
position: relative;
|
||||||
align-self: stretch;
|
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;
|
overflow: hidden;
|
||||||
/* Links und rechts läuft der Streifen ins Dunkel aus */
|
/* Links und rechts läuft der Streifen ins Dunkel aus */
|
||||||
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
|
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
|
||||||
@@ -557,7 +212,7 @@ async function oeffnen(item) {
|
|||||||
top: 20px;
|
top: 20px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: var(--luecke);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
/* Die Kurve setzt das Skript je Phase */
|
/* Die Kurve setzt das Skript je Phase */
|
||||||
transition-property: transform;
|
transition-property: transform;
|
||||||
@@ -565,8 +220,8 @@ async function oeffnen(item) {
|
|||||||
|
|
||||||
.kachel {
|
.kachel {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 220px;
|
flex: 0 0 var(--kachel);
|
||||||
height: 330px;
|
height: calc(var(--kachel) * 1.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 2px solid;
|
border: 2px solid;
|
||||||
@@ -634,6 +289,28 @@ async function oeffnen(item) {
|
|||||||
pointer-events: none;
|
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 */
|
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
|
||||||
.marke.anlauf {
|
.marke.anlauf {
|
||||||
animation: puls 0.33s ease-in-out 3;
|
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 {
|
.leer-bahn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -702,6 +357,21 @@ async function oeffnen(item) {
|
|||||||
gap: 0.7rem;
|
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 {
|
.reset {
|
||||||
padding: 0.7rem 1.4rem;
|
padding: 0.7rem 1.4rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -721,73 +391,6 @@ async function oeffnen(item) {
|
|||||||
cursor: default;
|
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 {
|
.gewinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -837,111 +440,6 @@ async function oeffnen(item) {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.note {
|
||||||
padding: 3rem 0;
|
padding: 3rem 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -504,6 +504,12 @@ h3 {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.aktionen {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.weg {
|
.weg {
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
58
frontend/src/composables/useEinstellungen.js
Normal file
58
frontend/src/composables/useEinstellungen.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
179
frontend/src/composables/useFilter.js
Normal file
179
frontend/src/composables/useFilter.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
150
frontend/src/composables/useKatalog.js
Normal file
150
frontend/src/composables/useKatalog.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
81
frontend/src/composables/useLangeListe.js
Normal file
81
frontend/src/composables/useLangeListe.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
124
frontend/src/composables/useMeins.js
Normal file
124
frontend/src/composables/useMeins.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
287
frontend/src/composables/useZiehung.js
Normal file
287
frontend/src/composables/useZiehung.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/klang.js
Normal file
50
frontend/src/klang.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user