update
This commit is contained in:
94
README.md
94
README.md
@@ -1,10 +1,12 @@
|
|||||||
# Horrorfilme
|
# Filmabend
|
||||||
|
|
||||||
Privater Katalog-Browser mit zwei Reitern:
|
Privater Katalog-Browser für den wöchentlichen Filmabend. Vier Reiter:
|
||||||
|
|
||||||
- **Netflix** — alle Horrorfilme im Netflix-DE-Abo, aktuell 325.
|
- **Netflix** — alles im Netflix-DE-Abo ab 50 Stimmen, aktuell 2567 Filme.
|
||||||
- **Kanon** — 194 Einträge aus anerkannten Bestenlisten, davon 28 Filmreihen.
|
- **Kanon** — 194 Einträge aus Horror-Bestenlisten, davon 28 Filmreihen.
|
||||||
Zusammen 320 Filme, unabhängig davon, wo sie laufen.
|
Zusammen 319 Filme, unabhängig davon, wo sie laufen.
|
||||||
|
- **Suche** — freie TMDB-Suche für alles, was in keinem der beiden steht.
|
||||||
|
- **Rad** — Glücksrad über die eingereichten Vorschläge.
|
||||||
|
|
||||||
Daten kommen von TMDB (Provider-Info stammt dort aus der JustWatch-Partnerschaft).
|
Daten kommen von TMDB (Provider-Info stammt dort aus der JustWatch-Partnerschaft).
|
||||||
Die deutschen Teaser schreibt MiniMax M3 beim Sync und landen in der SQLite-Datei.
|
Die deutschen Teaser schreibt MiniMax M3 beim Sync und landen in der SQLite-Datei.
|
||||||
@@ -81,6 +83,8 @@ FastAPI /api/movies ──► Vue-Grid, Detail-Overlay
|
|||||||
| `GET /api/movies` | ganzer Katalog, das Frontend filtert clientseitig |
|
| `GET /api/movies` | ganzer Katalog, das Frontend filtert clientseitig |
|
||||||
| `GET /api/movies/{id}` | einzelner Film |
|
| `GET /api/movies/{id}` | einzelner Film |
|
||||||
| `GET /api/status` | Anzahl Filme, letzter Sync, läuft gerade einer |
|
| `GET /api/status` | Anzahl Filme, letzter Sync, läuft gerade einer |
|
||||||
|
| `GET /api/search?q=` | freie TMDB-Suche, nicht auf den Katalog beschränkt |
|
||||||
|
| `GET /api/search/{id}` | volle Daten, erst aus dem Katalog, sonst von TMDB |
|
||||||
| `GET /api/health` | für den Docker-Healthcheck |
|
| `GET /api/health` | für den Docker-Healthcheck |
|
||||||
| `POST /api/sync` | Sync von Hand anstoßen |
|
| `POST /api/sync` | Sync von Hand anstoßen |
|
||||||
|
|
||||||
@@ -90,8 +94,63 @@ Gehört ein Film zu einer Reihe, zeigt die Detailansicht die anderen Teile —
|
|||||||
aber nur die, die selbst auf Netflix liegen. Grundlage ist TMDBs
|
aber nur die, die selbst auf Netflix liegen. Grundlage ist TMDBs
|
||||||
`belongs_to_collection`. Ein Klick springt direkt zum nächsten Teil.
|
`belongs_to_collection`. Ein Klick springt direkt zum nächsten Teil.
|
||||||
|
|
||||||
Aktuell haben 86 Filme eine Reihe, 24 Reihen sind mit mehr als einem Teil
|
Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus
|
||||||
vertreten. Die größte ist Resident Evil mit fünf Filmen.
|
2567 Netflix-Kacheln werden so 2369 — Herr der Ringe belegt eine statt drei.
|
||||||
|
Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr
|
||||||
|
Platz im Raster.
|
||||||
|
|
||||||
|
## Vorschläge und Rad
|
||||||
|
|
||||||
|
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
||||||
|
vorschlagen — erkannt über ein Cookie, ohne Anmeldung. Ein zweiter Vorschlag
|
||||||
|
ersetzt den ersten, statt abgelehnt zu werden.
|
||||||
|
|
||||||
|
Vorgeschlagen wird aus der Detailansicht heraus, mit optionalem Namen. Das
|
||||||
|
klappt auch mit Filmen aus dem Suche-Reiter, die gar nicht im Katalog stehen:
|
||||||
|
Titel, Jahr und Poster werden beim Vorschlagen mitgespeichert.
|
||||||
|
|
||||||
|
Das Rad verteilt die Vorschläge auf gleich große Segmente und dreht fünf bis
|
||||||
|
sieben Runden. Der Zeiger steht oben; das Ziel wird ausgelost und die Drehung
|
||||||
|
so berechnet, dass die Segmentmitte dort landet.
|
||||||
|
|
||||||
|
**Gedreht wird für alle gleichzeitig.** Die Ziehung passiert im Server, nicht
|
||||||
|
im Browser — sonst sähe jede Seite ein anderes Ergebnis. Wer auf „Drehen"
|
||||||
|
klickt, stößt nur an; das Ergebnis samt Zielwinkel geht per Server-Sent Events
|
||||||
|
an alle offenen Seiten. Neue Vorschläge kommen auf demselben Weg an.
|
||||||
|
|
||||||
|
Wer die Seite mitten im Dreh lädt, fragt `GET /api/spin` und steigt in die
|
||||||
|
Restanimation ein. Ist der Dreh schon durch, springt das Rad ohne Animation
|
||||||
|
auf die Endstellung — sonst zeigte der Zeiger woanders hin als die
|
||||||
|
Gewinnerkarte.
|
||||||
|
|
||||||
|
Die Abonnenten liegen im Speicher des Prozesses. Das trägt genau so weit wie
|
||||||
|
ein einzelner uvicorn-Worker; bei mehreren bekäme jeder nur seine eigenen.
|
||||||
|
|
||||||
|
| Endpoint | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/suggestions` | alle Vorschläge, plus die eigene ID |
|
||||||
|
| `POST /api/suggestions` | vorschlagen oder ersetzen |
|
||||||
|
| `DELETE /api/suggestions` | eigenen Vorschlag zurückziehen |
|
||||||
|
| `GET /api/events` | Live-Kanal (SSE) für Dreh und Vorschläge |
|
||||||
|
| `POST /api/spin` | Dreh für alle auslösen |
|
||||||
|
| `GET /api/spin` | laufenden oder letzten Dreh abfragen |
|
||||||
|
|
||||||
|
## Die Filterleiste
|
||||||
|
|
||||||
|
Rechts am Fensterrand, fest stehend. Von oben nach unten:
|
||||||
|
|
||||||
|
- Suche und Sortierung
|
||||||
|
- Vier Bereichsregler: Bewertungen, Dauer, Jahr, Bewertung. Die Skalen kommen
|
||||||
|
aus dem offenen Reiter und wechseln beim Umschalten mit. Bei den Stimmen ist
|
||||||
|
die Skala gestaucht — linear säßen fast alle Filme im linken Prozent.
|
||||||
|
- Genres zum Zeigen und zum Ausblenden, je als Badge-Gruppe mit "alle"
|
||||||
|
|
||||||
|
Eingeschlossene Genres wirken als ODER. Ein Ausschluss sticht immer: Ein
|
||||||
|
Actionfilm mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||||
|
|
||||||
|
Ein früherer Versuch mit acht Subgenres (Geister, Zombies, Slasher …) ist
|
||||||
|
wieder raus. TMDBs Schlagwörter reichten dafür nicht: 77 Filme hatten gar
|
||||||
|
keine, der Rest nur allgemeine wie "supernatural horror".
|
||||||
|
|
||||||
## Der Kanon-Reiter
|
## Der Kanon-Reiter
|
||||||
|
|
||||||
@@ -129,7 +188,7 @@ stillschweigend. Filme ohne ID versucht der nächste Sync erneut.
|
|||||||
Hat ein Film mehrere IDs, gewinnt die erste. Godzilla Minus One liegt bei
|
Hat ein Film mehrere IDs, gewinnt die erste. Godzilla Minus One liegt bei
|
||||||
Netflix in Farbe und in Schwarzweiß.
|
Netflix in Farbe und in Schwarzweiß.
|
||||||
|
|
||||||
## Warum der Katalog bei drei Stimmen abschneidet
|
## Warum der Katalog bei 50 Stimmen abschneidet
|
||||||
|
|
||||||
JustWatch ordnet unbekannte Titel gelegentlich der Netflix-ID eines
|
JustWatch ordnet unbekannte Titel gelegentlich der Netflix-ID eines
|
||||||
gleichnamigen bekannten Films zu. TMDB übernimmt das ungeprüft. Der Film
|
gleichnamigen bekannten Films zu. TMDB übernimmt das ungeprüft. Der Film
|
||||||
@@ -147,23 +206,28 @@ Nachgewiesen an fünf Fällen, alle nach demselben Muster:
|
|||||||
|
|
||||||
Nachweis über TMDBs Watch-Seiten: Dort steht die Netflix-ID im
|
Nachweis über TMDBs Watch-Seiten: Dort steht die Netflix-ID im
|
||||||
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
|
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
|
||||||
Oberhalb von zwei Stimmen trat kein einziger Fall auf, deshalb die Grenze
|
Oberhalb von zwei Stimmen trat kein einziger Fall auf. Die Grenze liegt
|
||||||
bei drei. Das kostet 11 Einträge von 336.
|
inzwischen bei 50 Stimmen, weil der Katalog alle Genres umfasst — das hält
|
||||||
|
die Fehlzuordnungen ohnehin draußen.
|
||||||
|
|
||||||
## Grenzen
|
## Grenzen
|
||||||
|
|
||||||
- Verfügbarkeit hinkt Netflix 24–48 h hinterher. So oft liefert JustWatch an TMDB.
|
- Verfügbarkeit hinkt Netflix 24–48 h hinterher. So oft liefert JustWatch an TMDB.
|
||||||
- Es gibt keine Info, ob ein Film deutsche Synchronisation hat. TMDB kennt nur
|
- Es gibt keine Info, ob ein Film deutsche Synchronisation hat. TMDB kennt nur
|
||||||
die Originalsprache, nicht die Tonspuren bei Netflix.
|
die Originalsprache, nicht die Tonspuren bei Netflix.
|
||||||
- Der Netflix-Knopf führt bei 310 von 325 Filmen direkt auf die Titelseite.
|
- Der Netflix-Knopf führt bei 730 von 2567 Filmen direkt auf die Titelseite.
|
||||||
Die restlichen 15 landen auf TMDBs Watch-Seite, weil dort kein Netflix-Link
|
Der Rest landet auf TMDBs Watch-Seite, von dort ist es ein Klick weiter.
|
||||||
hinterlegt ist. Der nächste Sync versucht sie erneut.
|
Pro Sync kommen 250 dazu, meistgesehene zuerst — mehr auf einmal drosselt
|
||||||
|
TMDB.
|
||||||
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
|
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
|
||||||
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
|
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
|
||||||
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
|
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
|
||||||
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
|
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
|
||||||
- 15 Filme haben gar keine Bilder, 46 keinen Trailer. Dann zeigt die
|
- Neun Filme haben gar keine Bilder. Dann zeigt die Detailansicht weniger.
|
||||||
Detailansicht entsprechend weniger.
|
- Der Teaser-Ton richtet sich nach dem Genre. Horror, Thriller, Mystery und
|
||||||
|
Krimi bleiben reißerisch, alles andere bekommt die Anweisung, den Ton des
|
||||||
|
Films zu treffen. Vorher wurde aus einer Bollywood-Romanze ein Dorf mit
|
||||||
|
verschlingenden Almwiesen.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ IMAGE_BASE = "https://image.tmdb.org/t/p"
|
|||||||
MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
|
MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
|
||||||
MINIMAX_MODEL = "MiniMax-M3"
|
MINIMAX_MODEL = "MiniMax-M3"
|
||||||
|
|
||||||
# Netflix = 8, Horror = 27
|
|
||||||
PROVIDER_NETFLIX = 8
|
PROVIDER_NETFLIX = 8
|
||||||
GENRE_HORROR = 27
|
|
||||||
REGION = "DE"
|
REGION = "DE"
|
||||||
LANGUAGE = "de-DE"
|
LANGUAGE = "de-DE"
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ def row_to_dict(row):
|
|||||||
|
|
||||||
|
|
||||||
def upsert(conn, movie):
|
def upsert(conn, movie):
|
||||||
payload = dict(movie)
|
columns = {row["name"] for row in conn.execute("PRAGMA table_info(movies)")}
|
||||||
|
# Der Sync trägt Hilfsfelder mit, etwa die Schlagwörter für die Einordnung
|
||||||
|
payload = {k: v for k, v in movie.items() if k in columns}
|
||||||
for field in JSON_FIELDS:
|
for field in JSON_FIELDS:
|
||||||
payload[field] = json.dumps(payload.get(field) or [], ensure_ascii=False)
|
payload[field] = json.dumps(payload.get(field) or [], ensure_ascii=False)
|
||||||
columns = ", ".join(payload)
|
columns = ", ".join(payload)
|
||||||
|
|||||||
62
backend/app/events.py
Normal file
62
backend/app/events.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Live-Kanal zu allen offenen Seiten.
|
||||||
|
|
||||||
|
Server-Sent Events reichen: Es fließt nur in eine Richtung, und der Browser
|
||||||
|
verbindet nach einem Abbruch von selbst neu. Ein WebSocket wäre mehr Technik
|
||||||
|
für dasselbe Ergebnis.
|
||||||
|
|
||||||
|
Die Abonnenten liegen im Speicher. Das trägt genau so weit wie ein einzelner
|
||||||
|
Prozess — bei mehreren uvicorn-Workern bekäme jeder nur seine eigenen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("events")
|
||||||
|
|
||||||
|
# Wenn ein Browser nicht mitkommt, sollen ihn alte Ereignisse nicht
|
||||||
|
# unbegrenzt im Speicher stapeln
|
||||||
|
PUFFER = 16
|
||||||
|
|
||||||
|
_abonnenten: set[asyncio.Queue] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def anmelden():
|
||||||
|
queue = asyncio.Queue(maxsize=PUFFER)
|
||||||
|
_abonnenten.add(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def abmelden(queue):
|
||||||
|
_abonnenten.discard(queue)
|
||||||
|
|
||||||
|
|
||||||
|
def anzahl():
|
||||||
|
return len(_abonnenten)
|
||||||
|
|
||||||
|
|
||||||
|
def senden(typ, daten=None):
|
||||||
|
"""An alle offenen Seiten schicken. Blockiert nie."""
|
||||||
|
nachricht = json.dumps({"type": typ, "data": daten}, ensure_ascii=False)
|
||||||
|
for queue in list(_abonnenten):
|
||||||
|
try:
|
||||||
|
queue.put_nowait(nachricht)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
log.warning("Abonnent kommt nicht mit, Ereignis verworfen")
|
||||||
|
|
||||||
|
|
||||||
|
async def stream(queue, request):
|
||||||
|
"""SSE-Datenstrom. Der Kommentar alle 20 s hält Proxys davon ab,
|
||||||
|
die Verbindung als tot zu schließen."""
|
||||||
|
try:
|
||||||
|
yield ": verbunden\n\n"
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
nachricht = await asyncio.wait_for(queue.get(), timeout=20)
|
||||||
|
yield f"data: {nachricht}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": ping\n\n"
|
||||||
|
finally:
|
||||||
|
abmelden(queue)
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import db, sync
|
from . import db, events, search, suggestions, sync
|
||||||
from .config import IMAGE_BASE, STATIC_DIR
|
from .config import IMAGE_BASE, STATIC_DIR
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
@@ -35,6 +37,7 @@ async def guarded_sync():
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
db.init()
|
db.init()
|
||||||
|
suggestions.init()
|
||||||
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"):
|
||||||
@@ -71,6 +74,137 @@ def movie(movie_id: int):
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
async def search_movies(q: str):
|
||||||
|
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
||||||
|
if len(q.strip()) < 2:
|
||||||
|
return []
|
||||||
|
return await search.search(q.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search/{movie_id}")
|
||||||
|
async def search_detail(movie_id: int):
|
||||||
|
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
||||||
|
found = db.get_movie(movie_id)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
try:
|
||||||
|
return await search.detail(movie_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
def sitzung(response: Response, sid):
|
||||||
|
"""Vorhandene Sitzung weiterverwenden, sonst eine neue setzen."""
|
||||||
|
if sid:
|
||||||
|
return sid
|
||||||
|
neu = suggestions.neue_session()
|
||||||
|
response.set_cookie(
|
||||||
|
suggestions.COOKIE,
|
||||||
|
neu,
|
||||||
|
max_age=60 * 60 * 24 * 365,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return neu
|
||||||
|
|
||||||
|
|
||||||
|
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
|
||||||
|
letzter_dreh = {"ergebnis": None, "start": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/events")
|
||||||
|
async def ereignisse(request: Request):
|
||||||
|
queue = events.anmelden()
|
||||||
|
return StreamingResponse(
|
||||||
|
events.stream(queue, request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/spin")
|
||||||
|
def drehen():
|
||||||
|
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
||||||
|
laeuft = (
|
||||||
|
letzter_dreh["ergebnis"]
|
||||||
|
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
|
||||||
|
)
|
||||||
|
if laeuft:
|
||||||
|
return {"started": False, "reason": "dreht bereits"}
|
||||||
|
|
||||||
|
ergebnis = suggestions.ziehen()
|
||||||
|
if not ergebnis:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Vorschläge")
|
||||||
|
|
||||||
|
letzter_dreh["ergebnis"] = ergebnis
|
||||||
|
letzter_dreh["start"] = time.monotonic()
|
||||||
|
events.senden("spin", ergebnis)
|
||||||
|
return {"started": True, **ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/spin")
|
||||||
|
def letzter():
|
||||||
|
"""Für Seiten, die mitten im Dreh geladen werden."""
|
||||||
|
ergebnis = letzter_dreh["ergebnis"]
|
||||||
|
if not ergebnis:
|
||||||
|
return {"running": False, "result": None}
|
||||||
|
vergangen = time.monotonic() - letzter_dreh["start"]
|
||||||
|
return {
|
||||||
|
"running": vergangen < suggestions.DREH_DAUER,
|
||||||
|
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
||||||
|
"elapsed": round(vergangen, 2),
|
||||||
|
"result": ergebnis,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/suggestions")
|
||||||
|
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
eintraege = suggestions.alle()
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{k: v for k, v in e.items() if k != "session_id"} for e in eintraege
|
||||||
|
],
|
||||||
|
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
|
||||||
|
"mine": next(
|
||||||
|
(e["id"] for e in eintraege if e["session_id"] == sid),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/suggestions")
|
||||||
|
async def vorschlagen(
|
||||||
|
response: Response,
|
||||||
|
movie_id: int = Body(..., embed=True),
|
||||||
|
name: str | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
|
||||||
|
movie = db.get_movie(movie_id)
|
||||||
|
if not movie:
|
||||||
|
try:
|
||||||
|
movie = await search.detail(movie_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||||
|
|
||||||
|
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
||||||
|
eintrag = suggestions.ersetzen(sid, movie, name)
|
||||||
|
events.senden("suggestions")
|
||||||
|
return {k: v for k, v in eintrag.items() if k != "session_id"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/suggestions")
|
||||||
|
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
entfernt = suggestions.loeschen(sid)
|
||||||
|
if entfernt:
|
||||||
|
events.senden("suggestions")
|
||||||
|
return {"removed": entfernt}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sync")
|
@app.post("/api/sync")
|
||||||
async def trigger_sync():
|
async def trigger_sync():
|
||||||
if sync_lock.locked():
|
if sync_lock.locked():
|
||||||
|
|||||||
84
backend/app/search.py
Normal file
84
backend/app/search.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Freie Filmsuche über TMDB.
|
||||||
|
|
||||||
|
Der Katalog deckt nur Netflix und den Kanon ab. Für den Filmabend braucht
|
||||||
|
es auch Titel, die nirgends davon stehen — die kommen live von TMDB und
|
||||||
|
landen nicht in der Datenbank.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
|
||||||
|
from .tmdb import DETAIL_PARAMS, pick_backdrops, pick_overview, pick_trailer
|
||||||
|
|
||||||
|
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def client():
|
||||||
|
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
def as_result(item):
|
||||||
|
release = item.get("release_date") or ""
|
||||||
|
return {
|
||||||
|
"id": item["id"],
|
||||||
|
"title": item.get("title") or item.get("original_title") or "Ohne Titel",
|
||||||
|
"original_title": item.get("original_title"),
|
||||||
|
"year": int(release[:4]) if release[:4].isdigit() else None,
|
||||||
|
"release_date": release or None,
|
||||||
|
"overview": item.get("overview") or "",
|
||||||
|
"poster_path": item.get("poster_path"),
|
||||||
|
"vote_average": item.get("vote_average"),
|
||||||
|
"vote_count": item.get("vote_count"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search(query, limit=24):
|
||||||
|
async with client() as http:
|
||||||
|
resp = await http.get(
|
||||||
|
"/search/movie",
|
||||||
|
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
results = resp.json().get("results", [])
|
||||||
|
|
||||||
|
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
||||||
|
# Dubletten oder Making-ofs
|
||||||
|
usable = [r for r in results if r.get("poster_path")]
|
||||||
|
usable.sort(key=lambda r: -(r.get("popularity") or 0))
|
||||||
|
return [as_result(r) for r in usable[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
async def detail(movie_id):
|
||||||
|
"""Vollbild-Daten für einen Treffer, der nicht im Katalog steht."""
|
||||||
|
async with client() as http:
|
||||||
|
resp = await http.get(f"/movie/{movie_id}", params=DETAIL_PARAMS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
release = data.get("release_date") or ""
|
||||||
|
collection = data.get("belongs_to_collection") or {}
|
||||||
|
return {
|
||||||
|
"id": data["id"],
|
||||||
|
"title": data.get("title") or data.get("original_title") or "Ohne Titel",
|
||||||
|
"original_title": data.get("original_title"),
|
||||||
|
"release_date": release or None,
|
||||||
|
"year": int(release[:4]) if release[:4].isdigit() else None,
|
||||||
|
"overview": pick_overview(data),
|
||||||
|
"teaser": "",
|
||||||
|
"poster_path": data.get("poster_path"),
|
||||||
|
"backdrop_path": data.get("backdrop_path"),
|
||||||
|
"vote_average": data.get("vote_average"),
|
||||||
|
"vote_count": data.get("vote_count"),
|
||||||
|
"runtime": data.get("runtime"),
|
||||||
|
"genres": [g["name"] for g in data.get("genres", [])],
|
||||||
|
"images": pick_backdrops(data)[:12],
|
||||||
|
"trailer_key": pick_trailer(data),
|
||||||
|
"imdb_id": data.get("imdb_id"),
|
||||||
|
"netflix_id": None,
|
||||||
|
"netflix_url": None,
|
||||||
|
"on_netflix": 0,
|
||||||
|
"canon_group": None,
|
||||||
|
"canon_pos": None,
|
||||||
|
"collection_id": collection.get("id"),
|
||||||
|
"collection_name": collection.get("name"),
|
||||||
|
}
|
||||||
128
backend/app/suggestions.py
Normal file
128
backend/app/suggestions.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Filmvorschläge fürs Rad.
|
||||||
|
|
||||||
|
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
|
||||||
|
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
|
||||||
|
reicht es.
|
||||||
|
|
||||||
|
Titel, Jahr und Poster werden mitgespeichert. So bleibt ein Vorschlag
|
||||||
|
lesbar, auch wenn der Film nie im Katalog war oder Netflix ihn abgibt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL UNIQUE,
|
||||||
|
movie_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year INTEGER,
|
||||||
|
poster_path TEXT,
|
||||||
|
name TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
COOKIE = "filmabend_sid"
|
||||||
|
|
||||||
|
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
||||||
|
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
|
||||||
|
# trotzdem bekommt.
|
||||||
|
DREH_DAUER = 4.2
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
with connect() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def neue_session():
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def alle():
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM suggestions ORDER BY created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def von_session(session_id):
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM suggestions WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def anlegen(session_id, movie, name=None):
|
||||||
|
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
|
||||||
|
eintrag = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"movie_id": movie["id"],
|
||||||
|
"title": movie["title"],
|
||||||
|
"year": movie.get("year"),
|
||||||
|
"poster_path": movie.get("poster_path"),
|
||||||
|
"name": (name or "").strip()[:40] or None,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO suggestions "
|
||||||
|
"(session_id, movie_id, title, year, poster_path, name, created_at) "
|
||||||
|
"VALUES (:session_id, :movie_id, :title, :year, :poster_path, "
|
||||||
|
":name, :created_at)",
|
||||||
|
eintrag,
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
return von_session(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def ersetzen(session_id, movie, name=None):
|
||||||
|
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM suggestions WHERE session_id = ?", (session_id,))
|
||||||
|
return anlegen(session_id, movie, name)
|
||||||
|
|
||||||
|
|
||||||
|
def ziehen():
|
||||||
|
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
|
||||||
|
|
||||||
|
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
|
||||||
|
der Zeiger stünde am Ende auf einem anderen Segment.
|
||||||
|
"""
|
||||||
|
eintraege = alle()
|
||||||
|
if not eintraege:
|
||||||
|
return None
|
||||||
|
|
||||||
|
treffer = random.randrange(len(eintraege))
|
||||||
|
schritt = 360 / len(eintraege)
|
||||||
|
runden = 5 + random.randrange(3)
|
||||||
|
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin
|
||||||
|
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"index": treffer,
|
||||||
|
"winkel": winkel,
|
||||||
|
"dauer": DREH_DAUER,
|
||||||
|
"gewinner": {
|
||||||
|
k: v for k, v in eintraege[treffer].items() if k != "session_id"
|
||||||
|
},
|
||||||
|
"anzahl": len(eintraege),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def loeschen(session_id):
|
||||||
|
with connect() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
|
||||||
|
).rowcount
|
||||||
@@ -83,6 +83,11 @@ async def dedupe_images(movies):
|
|||||||
log.info("Bilder: %s → %s nach Dedup", before, after)
|
log.info("Bilder: %s → %s nach Dedup", before, after)
|
||||||
|
|
||||||
|
|
||||||
|
# TMDB drosselt die Watch-Seiten. Mehr als das pro Lauf bringt nur noch
|
||||||
|
# leere Antworten — der Rest kommt beim nächsten Sync dran.
|
||||||
|
LINKS_PRO_LAUF = 250
|
||||||
|
|
||||||
|
|
||||||
async def fill_netflix_links(movies, known):
|
async def fill_netflix_links(movies, known):
|
||||||
"""Direktlink auf die Netflix-Titelseite, sofern die ID auffindbar ist.
|
"""Direktlink auf die Netflix-Titelseite, sofern die ID auffindbar ist.
|
||||||
|
|
||||||
@@ -92,9 +97,14 @@ async def fill_netflix_links(movies, known):
|
|||||||
for movie in movies:
|
for movie in movies:
|
||||||
movie["netflix_id"] = known.get(movie["id"])
|
movie["netflix_id"] = known.get(movie["id"])
|
||||||
|
|
||||||
todo = [m["id"] for m in movies if not m["netflix_id"]]
|
offen = [m for m in movies if not m["netflix_id"]]
|
||||||
|
# Die meistgesehenen zuerst, die klickt man am ehesten an
|
||||||
|
offen.sort(key=lambda m: -(m["vote_count"] or 0))
|
||||||
|
todo = [m["id"] for m in offen[:LINKS_PRO_LAUF]]
|
||||||
if todo:
|
if todo:
|
||||||
log.info("Suche Netflix-IDs für %s Filme", len(todo))
|
log.info(
|
||||||
|
"Suche Netflix-IDs für %s von %s offenen Filmen", len(todo), len(offen)
|
||||||
|
)
|
||||||
found = await netflix.fetch_ids(todo)
|
found = await netflix.fetch_ids(todo)
|
||||||
for movie in movies:
|
for movie in movies:
|
||||||
movie["netflix_id"] = movie["netflix_id"] or found.get(movie["id"])
|
movie["netflix_id"] = movie["netflix_id"] or found.get(movie["id"])
|
||||||
@@ -140,9 +150,8 @@ async def run():
|
|||||||
log.info("%s Kanon-Titel aufgelöst", len(canon_places))
|
log.info("%s Kanon-Titel aufgelöst", len(canon_places))
|
||||||
|
|
||||||
async with tmdb.client() as http:
|
async with tmdb.client() as http:
|
||||||
listed = await tmdb.discover_all(http)
|
netflix_ids = await tmdb.discover_all(http)
|
||||||
netflix_ids = {m["id"] for m in listed}
|
log.info("%s Filme bei Netflix DE gefunden", len(netflix_ids))
|
||||||
log.info("%s Filme bei Netflix DE gefunden", len(listed))
|
|
||||||
wanted = sorted(netflix_ids | set(canon_places))
|
wanted = sorted(netflix_ids | set(canon_places))
|
||||||
movies = await fetch_details(http, wanted)
|
movies = await fetch_details(http, wanted)
|
||||||
|
|
||||||
@@ -172,8 +181,8 @@ async def run():
|
|||||||
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Sync fertig: %s Filme (%s Netflix, %s Kanon), %s Netflix-Links, "
|
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
|
||||||
"%s neue Teaser, %s entfernt",
|
"%s Netflix-Links, %s neue Teaser, %s entfernt",
|
||||||
len(movies),
|
len(movies),
|
||||||
len(on_netflix),
|
len(on_netflix),
|
||||||
sum(1 for m in movies if m["canon_group"] is not None),
|
sum(1 for m in movies if m["canon_group"] is not None),
|
||||||
|
|||||||
@@ -6,16 +6,30 @@ from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
|
|||||||
|
|
||||||
THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
|
THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||||
|
|
||||||
SYSTEM = (
|
BASIS = (
|
||||||
"Du schreibst kurze deutsche Teaser für Horrorfilme. "
|
"Du schreibst kurze deutsche Teaser für Filme. "
|
||||||
"Antworte immer auf Deutsch, auch wenn die Vorlage englisch ist. "
|
"Antworte immer auf Deutsch, auch wenn die Vorlage englisch ist. "
|
||||||
"Übersetze die Vorlage nicht, sondern formuliere neu. "
|
"Übersetze die Vorlage nicht, sondern formuliere neu. "
|
||||||
"Genau zwei Sätze, zusammen höchstens 40 Wörter. "
|
"Genau zwei Sätze, zusammen höchstens 40 Wörter. "
|
||||||
"Atmosphärisch und reißerisch, aber ohne Spoiler und ohne das Ende zu verraten. "
|
"Ohne Spoiler und ohne das Ende zu verraten. "
|
||||||
"Kein Filmtitel im Text, keine Anführungszeichen, keine Einleitung. "
|
"Kein Filmtitel im Text, keine Anführungszeichen, keine Einleitung. "
|
||||||
"Gib nur den Teaser aus."
|
"Gib nur den Teaser aus."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ein Horror-Ton über allem macht aus einer Bollywood-Romanze ein
|
||||||
|
# Dorf-Mysterium. Der Ton muss zum Genre passen.
|
||||||
|
TON_HORROR = " Schreibe atmosphärisch und reißerisch."
|
||||||
|
TON_NEUTRAL = (
|
||||||
|
" Triff den Ton des Films: eine Komödie klingt leicht, ein Drama ernst, "
|
||||||
|
"ein Abenteuer nach Aufbruch. Erfinde keine Bedrohung, die es nicht gibt."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def system_prompt(movie):
|
||||||
|
genres = movie.get("genres") or []
|
||||||
|
duester = {"Horror", "Thriller", "Mystery", "Krimi"}
|
||||||
|
return BASIS + (TON_HORROR if duester & set(genres) else TON_NEUTRAL)
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(movie):
|
def build_prompt(movie):
|
||||||
year = movie.get("year") or "unbekannt"
|
year = movie.get("year") or "unbekannt"
|
||||||
@@ -51,7 +65,7 @@ def build_payload(movie):
|
|||||||
return {
|
return {
|
||||||
"model": MINIMAX_MODEL,
|
"model": MINIMAX_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": SYSTEM},
|
{"role": "system", "content": system_prompt(movie)},
|
||||||
{"role": "user", "content": build_prompt(movie)},
|
{"role": "user", "content": build_prompt(movie)},
|
||||||
],
|
],
|
||||||
# M3 denkt sonst über das Token-Limit hinaus und liefert nur den <think>-Block
|
# M3 denkt sonst über das Token-Limit hinaus und liefert nur den <think>-Block
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
GENRE_HORROR,
|
|
||||||
LANGUAGE,
|
LANGUAGE,
|
||||||
PROVIDER_NETFLIX,
|
PROVIDER_NETFLIX,
|
||||||
REGION,
|
REGION,
|
||||||
@@ -12,16 +11,15 @@ from .config import (
|
|||||||
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
||||||
|
|
||||||
DISCOVER_PARAMS = {
|
DISCOVER_PARAMS = {
|
||||||
"with_watch_providers": PROVIDER_NETFLIX,
|
|
||||||
"watch_region": REGION,
|
"watch_region": REGION,
|
||||||
"with_genres": GENRE_HORROR,
|
|
||||||
"with_watch_monetization_types": "flatrate",
|
"with_watch_monetization_types": "flatrate",
|
||||||
"language": LANGUAGE,
|
"language": LANGUAGE,
|
||||||
"include_adult": "false",
|
"include_adult": "false",
|
||||||
# JustWatch hängt unbekannte Titel gelegentlich an die Netflix-ID eines
|
# Der volle Katalog hat 5648 Titel, das meiste davon nie gesehen.
|
||||||
# gleichnamigen bekannten Films. Betroffen waren ausschließlich Einträge
|
# Die Grenze hält auch die Fehlzuordnungen draußen: JustWatch hängt
|
||||||
# mit ein oder zwei Stimmen — "LIENZO" bekam die ID von "Die Leinwand".
|
# unbekannte Titel gelegentlich an die Netflix-ID eines gleichnamigen
|
||||||
"vote_count.gte": 3,
|
# bekannten Films — "LIENZO" bekam die ID von "Die Leinwand".
|
||||||
|
"vote_count.gte": 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
DETAIL_PARAMS = {
|
DETAIL_PARAMS = {
|
||||||
@@ -37,19 +35,26 @@ def client():
|
|||||||
|
|
||||||
|
|
||||||
async def discover_page(http, page):
|
async def discover_page(http, page):
|
||||||
resp = await http.get("/discover/movie", params={**DISCOVER_PARAMS, "page": page})
|
resp = await http.get(
|
||||||
|
"/discover/movie",
|
||||||
|
params={
|
||||||
|
**DISCOVER_PARAMS,
|
||||||
|
"with_watch_providers": PROVIDER_NETFLIX,
|
||||||
|
"page": page,
|
||||||
|
},
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def discover_all(http):
|
async def discover_all(http):
|
||||||
"""Alle Netflix-DE-Horrorfilme durchpaginieren."""
|
"""Alle Film-IDs im Netflix-DE-Abo."""
|
||||||
first = await discover_page(http, 1)
|
first = await discover_page(http, 1)
|
||||||
movies = list(first["results"])
|
ids = {m["id"] for m in first["results"]}
|
||||||
for page in range(2, first["total_pages"] + 1):
|
for page in range(2, first["total_pages"] + 1):
|
||||||
data = await discover_page(http, page)
|
data = await discover_page(http, page)
|
||||||
movies.extend(data["results"])
|
ids |= {m["id"] for m in data["results"]}
|
||||||
return movies
|
return ids
|
||||||
|
|
||||||
|
|
||||||
async def detail(http, movie_id):
|
async def detail(http, movie_id):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Horror auf Netflix</title>
|
<title>Filmabend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import MovieCard from './components/MovieCard.vue'
|
import MovieCard from './components/MovieCard.vue'
|
||||||
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
|
import SearchTab from './components/SearchTab.vue'
|
||||||
|
import WheelTab from './components/WheelTab.vue'
|
||||||
import SeriesCard from './components/SeriesCard.vue'
|
import SeriesCard from './components/SeriesCard.vue'
|
||||||
|
|
||||||
const movies = ref([])
|
const movies = ref([])
|
||||||
@@ -12,14 +15,156 @@ const search = ref('')
|
|||||||
const sort = ref('rating')
|
const sort = ref('rating')
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const activeTab = ref('netflix')
|
const activeTab = ref('netflix')
|
||||||
|
const include = ref([])
|
||||||
|
const exclude = ref([])
|
||||||
|
|
||||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||||
const canonMovies = computed(() =>
|
const canonMovies = computed(() =>
|
||||||
movies.value.filter((m) => m.canon_group !== null)
|
movies.value.filter((m) => m.canon_group !== null)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
|
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||||
// zusammen, damit die Karte durch ihre Teile blättern kann.
|
// 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const grenzen = computed(() => {
|
||||||
|
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(() => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.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 canonGroups = computed(() => {
|
||||||
const byGroup = new Map()
|
const byGroup = new Map()
|
||||||
for (const movie of canonMovies.value) {
|
for (const movie of canonMovies.value) {
|
||||||
@@ -28,30 +173,46 @@ const canonGroups = computed(() => {
|
|||||||
}
|
}
|
||||||
return [...byGroup.entries()]
|
return [...byGroup.entries()]
|
||||||
.sort((a, b) => a[0] - b[0])
|
.sort((a, b) => a[0] - b[0])
|
||||||
.map(([group, parts]) => {
|
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
|
||||||
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
|
})
|
||||||
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
|
|
||||||
const sorted = parts.sort(
|
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
|
||||||
(a, b) =>
|
const netflixEntries = computed(() => {
|
||||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
|
||||||
a.canon_pos - b.canon_pos
|
return [
|
||||||
)
|
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
|
||||||
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
|
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
|
||||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt über
|
]
|
||||||
// den Platz der ganzen Reihe
|
|
||||||
let best = 0
|
|
||||||
sorted.forEach((part, i) => {
|
|
||||||
if (score(part) > score(sorted[best])) best = i
|
|
||||||
})
|
|
||||||
return { group, parts: sorted, best }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||||
|
{ id: 'search', label: 'Suche', count: null },
|
||||||
|
{ id: 'wheel', label: 'Rad', count: null },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const rad = ref(null)
|
||||||
|
|
||||||
|
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||||
|
const meinVorschlag = ref(null)
|
||||||
|
|
||||||
|
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
|
||||||
|
} catch {
|
||||||
|
meinVorschlag.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nachVorschlag() {
|
||||||
|
ladeVorschlag()
|
||||||
|
rad.value?.neu()
|
||||||
|
}
|
||||||
|
|
||||||
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
// 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.
|
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||||
const MIN_VOTES = 150
|
const MIN_VOTES = 150
|
||||||
@@ -84,25 +245,42 @@ function matches(movie, term) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = computed(() => {
|
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
|
||||||
const term = search.value.trim().toLowerCase()
|
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
|
||||||
const list = term
|
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||||
? netflixMovies.value.filter((m) => matches(m, term))
|
function passt(movie) {
|
||||||
: [...netflixMovies.value]
|
return hasGenre(movie) && imBereich(movie)
|
||||||
return list.sort(SORTS[sort.value])
|
}
|
||||||
})
|
|
||||||
|
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
|
||||||
|
// ihren stärksten Teil
|
||||||
|
function filterGruppen(entries, term) {
|
||||||
|
return entries
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.parts.some((m) => passt(m)) &&
|
||||||
|
(!term || e.parts.some((m) => matches(m, term)))
|
||||||
|
)
|
||||||
|
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = computed(() =>
|
||||||
|
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
||||||
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
||||||
const visibleCanon = computed(() => {
|
const visibleCanon = computed(() =>
|
||||||
const term = search.value.trim().toLowerCase()
|
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
||||||
const list = term
|
)
|
||||||
? canonGroups.value.filter((g) => g.parts.some((m) => matches(m, term)))
|
|
||||||
: [...canonGroups.value]
|
|
||||||
return list.sort((a, b) =>
|
|
||||||
SORTS[sort.value](a.parts[a.best], b.parts[b.best])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -127,14 +305,6 @@ const currentCount = computed(() =>
|
|||||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
||||||
)
|
)
|
||||||
|
|
||||||
const lastSync = computed(() => {
|
|
||||||
if (!status.value?.last_sync) return null
|
|
||||||
return new Date(status.value.last_sync).toLocaleString('de-DE', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -153,27 +323,14 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
ladeVorschlag()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
|
||||||
<header class="top">
|
|
||||||
<div class="brand">
|
|
||||||
<h1>Horrorfilme</h1>
|
|
||||||
<p v-if="lastSync" class="sync">Stand {{ lastSync }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
|
||||||
<select v-model="sort">
|
|
||||||
<option value="rating">Beste Bewertung</option>
|
|
||||||
<option value="newest">Neueste zuerst</option>
|
|
||||||
<option value="oldest">Älteste zuerst</option>
|
|
||||||
<option value="title">Titel A–Z</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -183,79 +340,90 @@ onMounted(load)
|
|||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
<span class="count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
|
||||||
<p v-else-if="!movies.length" class="note">
|
|
||||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
|
||||||
</p>
|
|
||||||
<p v-else-if="!currentCount" class="note">Nichts gefunden für „{{ search }}“.</p>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'netflix'" class="grid">
|
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
|
||||||
<MovieCard
|
|
||||||
v-for="movie in visible"
|
<div v-else class="layout">
|
||||||
:key="movie.id"
|
<div class="results">
|
||||||
:movie="movie"
|
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||||
@open="selected = $event"
|
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||||
|
<p v-else-if="!movies.length" class="note">
|
||||||
|
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!currentCount" class="note">
|
||||||
|
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<template
|
||||||
|
v-for="entry in activeTab === 'netflix' ? visible : visibleCanon"
|
||||||
|
:key="entry.group"
|
||||||
|
>
|
||||||
|
<MovieCard
|
||||||
|
v-if="entry.parts.length === 1"
|
||||||
|
:movie="entry.parts[0]"
|
||||||
|
@open="selected = $event"
|
||||||
|
/>
|
||||||
|
<SeriesCard
|
||||||
|
v-else
|
||||||
|
:parts="entry.parts"
|
||||||
|
:start="entry.best"
|
||||||
|
@open="selected = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterSidebar
|
||||||
|
v-if="genreOptions.length"
|
||||||
|
v-model:search="search"
|
||||||
|
v-model:sort="sort"
|
||||||
|
v-model:ranges="ranges"
|
||||||
|
:regler="REGLER"
|
||||||
|
:grenzen="grenzen"
|
||||||
|
:options="genreOptions"
|
||||||
|
:include="include"
|
||||||
|
:exclude="exclude"
|
||||||
|
@toggle="toggleGenre"
|
||||||
|
@all="toggleAll"
|
||||||
|
@reset="resetGenres"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid">
|
|
||||||
<template v-for="entry in visibleCanon" :key="entry.group">
|
|
||||||
<MovieCard
|
|
||||||
v-if="entry.parts.length === 1"
|
|
||||||
:movie="entry.parts[0]"
|
|
||||||
@open="selected = $event"
|
|
||||||
/>
|
|
||||||
<SeriesCard
|
|
||||||
v-else
|
|
||||||
:parts="entry.parts"
|
|
||||||
:start="entry.best"
|
|
||||||
@open="selected = $event"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MovieDetail
|
<MovieDetail
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
:movie="selected"
|
:movie="selected"
|
||||||
:series="series"
|
:series="series"
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
@open="selected = $event"
|
@open="selected = $event"
|
||||||
|
:mein-vorschlag="meinVorschlag"
|
||||||
|
@suggested="nachVorschlag"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer>
|
|
||||||
Daten von
|
|
||||||
<a href="https://www.themoviedb.org" target="_blank" rel="noopener">TMDB</a>
|
|
||||||
und JustWatch. This product uses the TMDB API but is not endorsed or certified
|
|
||||||
by TMDB.
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1400px;
|
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1.25rem 3rem;
|
padding: 1.5rem 1.25rem 3rem;
|
||||||
|
padding-right: calc(var(--sidebar) + 1.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top {
|
/* Such- und Rad-Reiter haben keine Filterleiste */
|
||||||
display: flex;
|
.page.breit {
|
||||||
flex-wrap: wrap;
|
padding-right: 1.25rem;
|
||||||
gap: 1rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,29 +454,45 @@ onMounted(load)
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.layout {
|
||||||
font-size: 1.6rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ohne min-width schiebt das Suchfeld auf schmalen Fenstern die Seite breit */
|
|
||||||
.controls input {
|
|
||||||
flex: 1 1 12rem;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout > :last-child {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
@@ -325,13 +509,4 @@ h1 {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
|
||||||
margin-top: 3rem;
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
239
frontend/src/components/FilterSidebar.vue
Normal file
239
frontend/src/components/FilterSidebar.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import RangeFilter from './RangeFilter.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
options: { type: Array, required: true },
|
||||||
|
include: { type: Array, required: true },
|
||||||
|
exclude: { type: Array, required: true },
|
||||||
|
regler: { type: Array, default: () => [] },
|
||||||
|
grenzen: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||||
|
|
||||||
|
const search = defineModel('search', { type: String, default: '' })
|
||||||
|
const sort = defineModel('sort', { type: String, default: 'rating' })
|
||||||
|
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||||
|
|
||||||
|
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||||
|
const etwasAktiv = computed(
|
||||||
|
() =>
|
||||||
|
props.include.length > 0 ||
|
||||||
|
props.exclude.length > 0 ||
|
||||||
|
props.regler.some((r) => {
|
||||||
|
const spanne = ranges.value[r.key]
|
||||||
|
const grenze = props.grenzen[r.key]
|
||||||
|
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
function beschriften(regler) {
|
||||||
|
return (wert) => {
|
||||||
|
// Jahreszahlen ohne Tausenderpunkt — sonst steht dort "2.026"
|
||||||
|
const zahl = regler.nachkomma
|
||||||
|
? wert.toFixed(regler.nachkomma)
|
||||||
|
: regler.roh
|
||||||
|
? String(wert)
|
||||||
|
: wert.toLocaleString('de-DE')
|
||||||
|
return zahl + (regler.einheit || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="controls">
|
||||||
|
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||||
|
<select v-model="sort">
|
||||||
|
<option value="rating">Beste Bewertung</option>
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="oldest">Älteste zuerst</option>
|
||||||
|
<option value="title">Titel A–Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="head">
|
||||||
|
<h3>Filter</h3>
|
||||||
|
<button v-if="etwasAktiv" type="button" class="reset" @click="emit('reset')">
|
||||||
|
zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="regler.length" class="regler-block">
|
||||||
|
<RangeFilter
|
||||||
|
v-for="eintrag in regler"
|
||||||
|
:key="eintrag.key"
|
||||||
|
:model-value="ranges[eintrag.key] || [0, 0]"
|
||||||
|
:label="eintrag.label"
|
||||||
|
:min="grenzen[eintrag.key]?.min ?? 0"
|
||||||
|
:max="grenzen[eintrag.key]?.max ?? 0"
|
||||||
|
:step="eintrag.step"
|
||||||
|
:gestaucht="!!eintrag.gestaucht"
|
||||||
|
:format="beschriften(eintrag)"
|
||||||
|
@update:model-value="ranges = { ...ranges, [eintrag.key]: $event }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>Genres zeigen</h4>
|
||||||
|
<div class="badges">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="badge alle"
|
||||||
|
:class="{ on: include.length === options.length }"
|
||||||
|
@click="emit('all', 'include')"
|
||||||
|
>
|
||||||
|
alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="'in-' + option.genre"
|
||||||
|
type="button"
|
||||||
|
class="badge"
|
||||||
|
:class="{ on: include.includes(option.genre) }"
|
||||||
|
@click="emit('toggle', 'include', option.genre)"
|
||||||
|
>
|
||||||
|
{{ option.genre }}
|
||||||
|
<span class="count">{{ option.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>Genres ausblenden</h4>
|
||||||
|
<div class="badges">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="badge alle"
|
||||||
|
:class="{ on: exclude.length === options.length }"
|
||||||
|
@click="emit('all', 'exclude')"
|
||||||
|
>
|
||||||
|
alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="'out-' + option.genre"
|
||||||
|
type="button"
|
||||||
|
class="badge out"
|
||||||
|
:class="{ on: exclude.includes(option.genre) }"
|
||||||
|
@click="emit('toggle', 'exclude', option.genre)"
|
||||||
|
>
|
||||||
|
{{ option.genre }}
|
||||||
|
<span class="count">{{ option.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 1rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding-bottom: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input,
|
||||||
|
.controls select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding-bottom: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
padding: 0.28rem 0.6rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.alle {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.on {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.out.on {
|
||||||
|
background: #4a4a56;
|
||||||
|
border-color: #4a4a56;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,12 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
|||||||
const rating = computed(() =>
|
const rating = computed(() =>
|
||||||
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
|
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const meta = computed(() =>
|
||||||
|
[props.movie.year || 'Jahr unbekannt', props.movie.runtime && `${props.movie.runtime} Min.`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,7 +29,7 @@ const rating = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h2>{{ movie.title }}</h2>
|
<h2>{{ movie.title }}</h2>
|
||||||
<p class="meta">{{ movie.year || 'Jahr unbekannt' }}</p>
|
<p class="meta">{{ meta }}</p>
|
||||||
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -87,6 +93,14 @@ const rating = computed(() =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
|
||||||
|
unterschiedlich weit nach unten */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: calc(3 * 1.3em);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@@ -102,6 +116,7 @@ h2 {
|
|||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
line-clamp: 4;
|
line-clamp: 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: calc(4 * 1.45em);
|
||||||
color: #b9b9c6;
|
color: #b9b9c6;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { backdropUrl, posterUrl } from '../images'
|
import { backdropUrl, posterUrl } from '../images'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
movie: { type: Object, required: true },
|
movie: { type: Object, required: true },
|
||||||
series: { type: Array, default: () => [] },
|
series: { type: Array, default: () => [] },
|
||||||
|
meinVorschlag: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'open'])
|
const emit = defineEmits(['close', 'open', 'suggested'])
|
||||||
|
|
||||||
|
const vorschlagen = ref({ zustand: 'bereit', name: '' })
|
||||||
|
|
||||||
|
const schonVorgeschlagen = computed(
|
||||||
|
() => props.meinVorschlag?.movie_id === props.movie.id
|
||||||
|
)
|
||||||
|
|
||||||
|
async function abschicken() {
|
||||||
|
vorschlagen.value = { ...vorschlagen.value, zustand: 'laeuft' }
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
movie_id: props.movie.id,
|
||||||
|
name: vorschlagen.value.name || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error()
|
||||||
|
vorschlagen.value = { ...vorschlagen.value, zustand: 'fertig' }
|
||||||
|
emit('suggested')
|
||||||
|
} catch {
|
||||||
|
vorschlagen.value = { ...vorschlagen.value, zustand: 'fehler' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||||
const shots = computed(() =>
|
const shots = computed(() =>
|
||||||
@@ -86,6 +112,37 @@ onUnmounted(() => {
|
|||||||
IMDb
|
IMDb
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="vorschlag">
|
||||||
|
<template v-if="vorschlagen.zustand === 'fertig'">
|
||||||
|
<span class="ok">Für den Filmabend vorgeschlagen.</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="schonVorgeschlagen">
|
||||||
|
<span class="ok">Dieser Film steht schon auf dem Rad.</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="meinVorschlag" class="hinweis">
|
||||||
|
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
|
||||||
|
Vorschlag ersetzt ihn.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="vorschlagen.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Dein Name (optional)"
|
||||||
|
maxlength="40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||||
|
@click="abschicken"
|
||||||
|
>
|
||||||
|
Vorschlagen
|
||||||
|
</button>
|
||||||
|
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||||||
|
Hat nicht geklappt.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -257,6 +314,57 @@ h2 {
|
|||||||
background: #f5c518;
|
background: #f5c518;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vorschlag {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschlag input {
|
||||||
|
width: 11rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschlag button {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschlag button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschlag button:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hinweis {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #6bbf7a;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fehler {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.netflix:hover,
|
.netflix:hover,
|
||||||
.imdb:hover {
|
.imdb:hover {
|
||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
|
|||||||
159
frontend/src/components/RangeFilter.vue
Normal file
159
frontend/src/components/RangeFilter.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
min: { type: Number, required: true },
|
||||||
|
max: { type: Number, required: true },
|
||||||
|
step: { type: Number, default: 1 },
|
||||||
|
// Bei den Stimmen liegt der Median weit unter dem Maximum. Linear säßen
|
||||||
|
// fast alle Filme im linken Prozent des Reglers.
|
||||||
|
gestaucht: { type: Boolean, default: false },
|
||||||
|
format: { type: Function, default: (v) => String(v) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = defineModel({ type: Array, required: true })
|
||||||
|
|
||||||
|
const SCHRITTE = 1000
|
||||||
|
|
||||||
|
function zuRegler(wert) {
|
||||||
|
if (props.max === props.min) return 0
|
||||||
|
const anteil = (wert - props.min) / (props.max - props.min)
|
||||||
|
return Math.round((props.gestaucht ? Math.sqrt(anteil) : anteil) * SCHRITTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
function zuWert(pos) {
|
||||||
|
const anteil = pos / SCHRITTE
|
||||||
|
const roh = props.min + (props.gestaucht ? anteil ** 2 : anteil) * (props.max - props.min)
|
||||||
|
return Math.round(roh / props.step) * props.step
|
||||||
|
}
|
||||||
|
|
||||||
|
const von = computed({
|
||||||
|
get: () => zuRegler(value.value[0]),
|
||||||
|
set: (pos) => {
|
||||||
|
const neu = Math.min(zuWert(Number(pos)), value.value[1])
|
||||||
|
value.value = [neu, value.value[1]]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const bis = computed({
|
||||||
|
get: () => zuRegler(value.value[1]),
|
||||||
|
set: (pos) => {
|
||||||
|
const neu = Math.max(zuWert(Number(pos)), value.value[0])
|
||||||
|
value.value = [value.value[0], neu]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const aktiv = computed(
|
||||||
|
() => value.value[0] > props.min || value.value[1] < props.max
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="range" :class="{ aktiv }">
|
||||||
|
<div class="kopf">
|
||||||
|
<span class="label">{{ label }}</span>
|
||||||
|
<span class="wert">{{ format(value[0]) }} – {{ format(value[1]) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="regler">
|
||||||
|
<input v-model="von" type="range" :min="0" :max="SCHRITTE" />
|
||||||
|
<input v-model="bis" type="range" :min="0" :max="SCHRITTE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.range {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wert {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktiv .wert {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beide Griffe liegen übereinander, sonst bräuchte es zwei Zeilen */
|
||||||
|
.regler {
|
||||||
|
position: relative;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
pointer-events: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-webkit-slider-runnable-track {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-moz-range-track {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-webkit-slider-thumb {
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
margin-top: -0.3rem;
|
||||||
|
background: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-moz-range-thumb {
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
background: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktiv .regler input::-webkit-slider-thumb {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktiv .regler input::-moz-range-thumb {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
frontend/src/components/SearchTab.vue
Normal file
105
frontend/src/components/SearchTab.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import MovieCard from './MovieCard.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['open'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let laufende = 0
|
||||||
|
|
||||||
|
async function run(term) {
|
||||||
|
if (term.trim().length < 2) {
|
||||||
|
results.value = []
|
||||||
|
searched.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meine = ++laufende
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
|
||||||
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
|
const data = await resp.json()
|
||||||
|
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||||
|
if (meine !== laufende) return
|
||||||
|
results.value = data
|
||||||
|
searched.value = true
|
||||||
|
} catch (err) {
|
||||||
|
if (meine === laufende) error.value = err.message
|
||||||
|
} finally {
|
||||||
|
if (meine === laufende) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (term) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => run(term), 350)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function open(movie) {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok) emit('open', await resp.json())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-tab">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
class="big-search"
|
||||||
|
placeholder="Film suchen — egal wo er läuft…"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="loading" class="note">Suche läuft…</p>
|
||||||
|
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||||
|
<p v-else-if="searched && !results.length" class="note">
|
||||||
|
Nichts gefunden für „{{ query }}“.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!searched" class="note">
|
||||||
|
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
|
||||||
|
der Katalog.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<MovieCard
|
||||||
|
v-for="movie in results"
|
||||||
|
:key="movie.id"
|
||||||
|
:movie="movie"
|
||||||
|
@open="open"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.big-search {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,12 @@ const rating = computed(() =>
|
|||||||
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
|
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const meta = computed(() =>
|
||||||
|
[current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
)
|
||||||
|
|
||||||
function step(delta) {
|
function step(delta) {
|
||||||
const count = props.parts.length
|
const count = props.parts.length
|
||||||
index.value = (index.value + delta + count) % count
|
index.value = (index.value + delta + count) % count
|
||||||
@@ -50,7 +56,7 @@ function step(delta) {
|
|||||||
<div class="body">
|
<div class="body">
|
||||||
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
|
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
|
||||||
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
||||||
<p class="meta">{{ current.year || 'Jahr unbekannt' }}</p>
|
<p class="meta">{{ meta }}</p>
|
||||||
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -123,6 +129,14 @@ function step(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
|
||||||
|
unterschiedlich weit nach unten */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: calc(3 * 1.3em);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -143,6 +157,7 @@ h2:hover {
|
|||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
line-clamp: 4;
|
line-clamp: 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: calc(4 * 1.45em);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: #b9b9c6;
|
color: #b9b9c6;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
433
frontend/src/components/WheelTab.vue
Normal file
433
frontend/src/components/WheelTab.vue
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { posterUrl } from '../images'
|
||||||
|
|
||||||
|
const emit = defineEmits(['open'])
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const mine = ref(null)
|
||||||
|
const laden = ref(true)
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
const winkel = ref(0)
|
||||||
|
const dreht = ref(false)
|
||||||
|
const gewinner = ref(null)
|
||||||
|
const dauer = ref(4.2)
|
||||||
|
|
||||||
|
const GROESSE = 420
|
||||||
|
const MITTE = GROESSE / 2
|
||||||
|
const RADIUS = MITTE - 6
|
||||||
|
|
||||||
|
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
||||||
|
// verschwimmt der Zeiger mit dem Rad
|
||||||
|
const FARBEN = [
|
||||||
|
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
||||||
|
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||||
|
let quelle = null
|
||||||
|
|
||||||
|
function verbinden() {
|
||||||
|
quelle = new EventSource('/api/events')
|
||||||
|
quelle.onmessage = (ereignis) => {
|
||||||
|
const nachricht = JSON.parse(ereignis.data)
|
||||||
|
if (nachricht.type === 'suggestions') laden_()
|
||||||
|
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||||
|
}
|
||||||
|
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||||
|
}
|
||||||
|
|
||||||
|
function abspielen(ergebnis, uebersprungen = 0) {
|
||||||
|
gewinner.value = null
|
||||||
|
dreht.value = true
|
||||||
|
|
||||||
|
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
|
||||||
|
if (uebersprungen > 0) {
|
||||||
|
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
|
||||||
|
dauer.value = rest
|
||||||
|
} else {
|
||||||
|
dauer.value = ergebnis.dauer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
|
||||||
|
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
dreht.value = false
|
||||||
|
gewinner.value = ergebnis.gewinner
|
||||||
|
}, rest * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: Rad ohne Animation auf die Endstellung setzen,
|
||||||
|
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
|
||||||
|
dauer.value = 0
|
||||||
|
winkel.value = daten.result.winkel
|
||||||
|
gewinner.value = daten.result.gewinner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* dann eben ohne */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laden_() {
|
||||||
|
laden.value = true
|
||||||
|
fehler.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/suggestions')
|
||||||
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||||
|
const daten = await resp.json()
|
||||||
|
items.value = daten.items
|
||||||
|
mine.value = daten.mine
|
||||||
|
} catch (err) {
|
||||||
|
fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await laden_()
|
||||||
|
await laufendenDrehHolen()
|
||||||
|
verbinden()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => quelle?.close())
|
||||||
|
|
||||||
|
defineExpose({ neu: laden_ })
|
||||||
|
|
||||||
|
const segmente = computed(() => {
|
||||||
|
const n = items.value.length
|
||||||
|
if (!n) return []
|
||||||
|
const schritt = 360 / n
|
||||||
|
return items.value.map((item, i) => {
|
||||||
|
const start = i * schritt
|
||||||
|
const ende = start + schritt
|
||||||
|
const mitte = start + schritt / 2
|
||||||
|
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
|
||||||
|
// von außen nach innen
|
||||||
|
const gespiegelt = mitte > 180
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
farbe: FARBEN[i % FARBEN.length],
|
||||||
|
pfad: kreisstueck(start, ende),
|
||||||
|
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
|
||||||
|
gespiegelt,
|
||||||
|
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
|
||||||
|
breit: schritt > 9,
|
||||||
|
kurz: kuerzen(item.title, schritt),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function kuerzen(titel, schritt) {
|
||||||
|
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
|
||||||
|
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
|
||||||
|
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
|
||||||
|
}
|
||||||
|
|
||||||
|
function punkt(grad) {
|
||||||
|
const rad = ((grad - 90) * Math.PI) / 180
|
||||||
|
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function kreisstueck(von, bis) {
|
||||||
|
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
|
||||||
|
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
|
||||||
|
if (bis - von >= 360) {
|
||||||
|
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
|
||||||
|
MITTE + RADIUS
|
||||||
|
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
|
||||||
|
}
|
||||||
|
const [x1, y1] = punkt(von)
|
||||||
|
const [x2, y2] = punkt(bis)
|
||||||
|
const gross = bis - von > 180 ? 1 : 0
|
||||||
|
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drehen() {
|
||||||
|
if (dreht.value || !items.value.length) return
|
||||||
|
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||||
|
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
|
||||||
|
} catch {
|
||||||
|
fehler.value = 'Drehen hat nicht geklappt.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oeffnen(item) {
|
||||||
|
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||||
|
if (resp.ok) emit('open', await resp.json())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wheel-tab">
|
||||||
|
<div class="buehne">
|
||||||
|
<p v-if="laden" class="note">Lade Vorschläge…</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<p v-else-if="!items.length" class="note">
|
||||||
|
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="rad-halter">
|
||||||
|
<div class="zeiger"></div>
|
||||||
|
<svg
|
||||||
|
class="rad"
|
||||||
|
:class="{ dreht }"
|
||||||
|
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||||
|
:style="{
|
||||||
|
transform: `rotate(${winkel}deg)`,
|
||||||
|
transitionDuration: `${dauer}s`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||||
|
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||||
|
<text
|
||||||
|
v-if="seg.breit"
|
||||||
|
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||||
|
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||||
|
:y="MITTE + 4"
|
||||||
|
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||||
|
class="beschriftung"
|
||||||
|
>
|
||||||
|
{{ seg.kurz }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
|
||||||
|
{{ dreht ? 'dreht…' : 'Drehen' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||||
|
<img
|
||||||
|
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||||
|
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||||
|
:alt="gewinner.title"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="label">Der Film des Abends</p>
|
||||||
|
<h2>{{ gewinner.title }}</h2>
|
||||||
|
<p class="jahr">
|
||||||
|
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside v-if="items.length" class="liste">
|
||||||
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(item, i) in items" :key="item.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="eintrag"
|
||||||
|
:class="{ eigen: item.id === mine }"
|
||||||
|
@click="oeffnen(item)"
|
||||||
|
>
|
||||||
|
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
|
||||||
|
<span class="titel">{{ item.title }}</span>
|
||||||
|
<span class="von">{{ item.name || '—' }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wheel-tab {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 280px;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buehne {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rad-halter {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, 80vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rad {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
|
||||||
|
transition-property: transform;
|
||||||
|
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beschriftung {
|
||||||
|
fill: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: rgba(0, 0, 0, 0.45);
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeiger {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-left: -11px;
|
||||||
|
border-top: 20px solid var(--text);
|
||||||
|
border-right: 11px solid transparent;
|
||||||
|
border-left: 11px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gewinner {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gewinner img {
|
||||||
|
width: 74px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gewinner h2 {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jahr {
|
||||||
|
color: var(--muted);
|
||||||
|
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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag:hover {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag.eigen .titel {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punkt {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titel {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.von {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wheel-tab {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
--text: #ececf1;
|
--text: #ececf1;
|
||||||
--muted: #8b8b99;
|
--muted: #8b8b99;
|
||||||
--accent: #e50914;
|
--accent: #e50914;
|
||||||
|
--sidebar: 310px;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user