This commit is contained in:
team3
2026-08-29 11:28:25 +02:00
parent 38d5a8cfae
commit f5aeaadc42
20 changed files with 1934 additions and 184 deletions

View File

@@ -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.
- **Kanon** — 194 Einträge aus anerkannten Bestenlisten, davon 28 Filmreihen.
Zusammen 320 Filme, unabhängig davon, wo sie laufen.
- **Netflix** — alles im Netflix-DE-Abo ab 50 Stimmen, aktuell 2567 Filme.
- **Kanon** — 194 Einträge aus Horror-Bestenlisten, davon 28 Filmreihen.
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).
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/{id}` | einzelner Film |
| `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 |
| `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
`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
vertreten. Die größte ist Resident Evil mit fünf Filmen.
Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus
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
@@ -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
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
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
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
Oberhalb von zwei Stimmen trat kein einziger Fall auf, deshalb die Grenze
bei drei. Das kostet 11 Einträge von 336.
Oberhalb von zwei Stimmen trat kein einziger Fall auf. Die Grenze liegt
inzwischen bei 50 Stimmen, weil der Katalog alle Genres umfasst — das hält
die Fehlzuordnungen ohnehin draußen.
## Grenzen
- 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
die Originalsprache, nicht die Tonspuren bei Netflix.
- Der Netflix-Knopf führt bei 310 von 325 Filmen direkt auf die Titelseite.
Die restlichen 15 landen auf TMDBs Watch-Seite, weil dort kein Netflix-Link
hinterlegt ist. Der nächste Sync versucht sie erneut.
- Der Netflix-Knopf führt bei 730 von 2567 Filmen direkt auf die Titelseite.
Der Rest landet auf TMDBs Watch-Seite, von dort ist es ein Klick weiter.
Pro Sync kommen 250 dazu, meistgesehene zuerst — mehr auf einmal drosselt
TMDB.
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
- 15 Filme haben gar keine Bilder, 46 keinen Trailer. Dann zeigt die
Detailansicht entsprechend weniger.
- Neun Filme haben gar keine Bilder. Dann zeigt die Detailansicht 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

View File

@@ -15,9 +15,7 @@ IMAGE_BASE = "https://image.tmdb.org/t/p"
MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
MINIMAX_MODEL = "MiniMax-M3"
# Netflix = 8, Horror = 27
PROVIDER_NETFLIX = 8
GENRE_HORROR = 27
REGION = "DE"
LANGUAGE = "de-DE"

View File

@@ -90,7 +90,9 @@ def row_to_dict(row):
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:
payload[field] = json.dumps(payload.get(field) or [], ensure_ascii=False)
columns = ", ".join(payload)

62
backend/app/events.py Normal file
View 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)

View File

@@ -1,12 +1,14 @@
import asyncio
import logging
import time
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.staticfiles import StaticFiles
from . import db, sync
from . import db, events, search, suggestions, sync
from .config import IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -35,6 +37,7 @@ async def guarded_sync():
@app.on_event("startup")
async def startup():
db.init()
suggestions.init()
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
@@ -71,6 +74,137 @@ def movie(movie_id: int):
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")
async def trigger_sync():
if sync_lock.locked():

84
backend/app/search.py Normal file
View 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
View 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

View File

@@ -83,6 +83,11 @@ async def dedupe_images(movies):
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):
"""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:
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:
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)
for movie in movies:
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))
async with tmdb.client() as http:
listed = await tmdb.discover_all(http)
netflix_ids = {m["id"] for m in listed}
log.info("%s Filme bei Netflix DE gefunden", len(listed))
netflix_ids = await tmdb.discover_all(http)
log.info("%s Filme bei Netflix DE gefunden", len(netflix_ids))
wanted = sorted(netflix_ids | set(canon_places))
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))
log.info(
"Sync fertig: %s Filme (%s Netflix, %s Kanon), %s Netflix-Links, "
"%s neue Teaser, %s entfernt",
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
"%s Netflix-Links, %s neue Teaser, %s entfernt",
len(movies),
len(on_netflix),
sum(1 for m in movies if m["canon_group"] is not None),

View File

@@ -6,16 +6,30 @@ from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
SYSTEM = (
"Du schreibst kurze deutsche Teaser für Horrorfilme. "
BASIS = (
"Du schreibst kurze deutsche Teaser für Filme. "
"Antworte immer auf Deutsch, auch wenn die Vorlage englisch ist. "
"Übersetze die Vorlage nicht, sondern formuliere neu. "
"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. "
"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):
year = movie.get("year") or "unbekannt"
@@ -51,7 +65,7 @@ def build_payload(movie):
return {
"model": MINIMAX_MODEL,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "system", "content": system_prompt(movie)},
{"role": "user", "content": build_prompt(movie)},
],
# M3 denkt sonst über das Token-Limit hinaus und liefert nur den <think>-Block

View File

@@ -1,7 +1,6 @@
import httpx
from .config import (
GENRE_HORROR,
LANGUAGE,
PROVIDER_NETFLIX,
REGION,
@@ -12,16 +11,15 @@ from .config import (
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
DISCOVER_PARAMS = {
"with_watch_providers": PROVIDER_NETFLIX,
"watch_region": REGION,
"with_genres": GENRE_HORROR,
"with_watch_monetization_types": "flatrate",
"language": LANGUAGE,
"include_adult": "false",
# JustWatch hängt unbekannte Titel gelegentlich an die Netflix-ID eines
# gleichnamigen bekannten Films. Betroffen waren ausschließlich Einträge
# mit ein oder zwei Stimmen — "LIENZO" bekam die ID von "Die Leinwand".
"vote_count.gte": 3,
# Der volle Katalog hat 5648 Titel, das meiste davon nie gesehen.
# Die Grenze hält auch die Fehlzuordnungen draußen: JustWatch hängt
# unbekannte Titel gelegentlich an die Netflix-ID eines gleichnamigen
# bekannten Films — "LIENZO" bekam die ID von "Die Leinwand".
"vote_count.gte": 50,
}
DETAIL_PARAMS = {
@@ -37,19 +35,26 @@ def client():
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()
return resp.json()
async def discover_all(http):
"""Alle Netflix-DE-Horrorfilme durchpaginieren."""
"""Alle Film-IDs im Netflix-DE-Abo."""
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):
data = await discover_page(http, page)
movies.extend(data["results"])
return movies
ids |= {m["id"] for m in data["results"]}
return ids
async def detail(http, movie_id):

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horror auf Netflix</title>
<title>Filmabend</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,7 +1,10 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.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'
const movies = ref([])
@@ -12,14 +15,156 @@ const search = ref('')
const sort = ref('rating')
const selected = ref(null)
const activeTab = ref('netflix')
const include = ref([])
const exclude = ref([])
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
const canonMovies = computed(() =>
movies.value.filter((m) => m.canon_group !== null)
)
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
// zusammen, damit die Karte durch ihre Teile blättern kann.
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
]
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 byGroup = new Map()
for (const movie of canonMovies.value) {
@@ -28,30 +173,46 @@ const canonGroups = computed(() => {
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => {
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
const sorted = parts.sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
a.canon_pos - b.canon_pos
)
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
// 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 }
})
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
})
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
const netflixEntries = computed(() => {
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
return [
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
]
})
const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.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.
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
const MIN_VOTES = 150
@@ -84,25 +245,42 @@ function matches(movie, term) {
)
}
const visible = computed(() => {
const term = search.value.trim().toLowerCase()
const list = term
? netflixMovies.value.filter((m) => matches(m, term))
: [...netflixMovies.value]
return list.sort(SORTS[sort.value])
})
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
function passt(movie) {
return hasGenre(movie) && imBereich(movie)
}
function hasGenre(movie) {
const genres = movie.genres || []
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
return false
}
return !include.value.length || genres.some((g) => include.value.includes(g))
}
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// 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
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
const visibleCanon = computed(() => {
const term = 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])
)
})
const visibleCanon = computed(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
)
// 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.
@@ -127,14 +305,6 @@ const currentCount = computed(() =>
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() {
loading.value = true
error.value = ''
@@ -153,27 +323,14 @@ async function load() {
}
}
onMounted(load)
onMounted(() => {
load()
ladeVorschlag()
})
</script>
<template>
<div class="page">
<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>
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
<nav class="tabs">
<button
v-for="tab in tabs"
@@ -183,79 +340,90 @@ onMounted(load)
@click="activeTab = tab.id"
>
{{ tab.label }}
<span class="count">{{ tab.count }}</span>
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
</nav>
<p v-if="loading" class="note">Lade Katalog…</p>
<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>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
<div v-if="activeTab === 'netflix'" class="grid">
<MovieCard
v-for="movie in visible"
:key="movie.id"
:movie="movie"
@open="selected = $event"
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
<div v-else class="layout">
<div class="results">
<p v-if="loading" class="note">Lade Katalog…</p>
<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 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
v-if="selected"
:movie="selected"
:series="series"
@close="selected = null"
@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>
</template>
<style scoped>
.page {
max-width: 1400px;
margin: 0 auto;
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
padding: 1.5rem 1.25rem 3rem;
padding-right: calc(var(--sidebar) + 1.25rem);
}
.top {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1rem;
/* Such- und Rad-Reiter haben keine Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
.tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 1.75rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
@@ -286,29 +454,45 @@ onMounted(load)
border-radius: 20px;
}
h1 {
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;
.layout {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
@@ -325,13 +509,4 @@ h1 {
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>

View 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>

View File

@@ -12,6 +12,12 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const rating = computed(() =>
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>
<template>
@@ -23,7 +29,7 @@ const rating = computed(() =>
</div>
<div class="body">
<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>
</div>
</button>
@@ -87,6 +93,14 @@ const rating = computed(() =>
}
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;
line-height: 1.3;
}
@@ -102,6 +116,7 @@ h2 {
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
min-height: calc(4 * 1.45em);
color: #b9b9c6;
font-size: 0.85rem;
line-height: 1.45;

View File

@@ -1,13 +1,39 @@
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { backdropUrl, posterUrl } from '../images'
const props = defineProps({
movie: { type: Object, required: true },
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 shots = computed(() =>
@@ -86,6 +112,37 @@ onUnmounted(() => {
IMDb
</a>
</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>
</header>
@@ -257,6 +314,57 @@ h2 {
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,
.imdb:hover {
filter: brightness(1.15);

View 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>

View 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>

View File

@@ -17,6 +17,12 @@ const rating = computed(() =>
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) {
const count = props.parts.length
index.value = (index.value + delta + count) % count
@@ -50,7 +56,7 @@ function step(delta) {
<div class="body">
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
<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>
</div>
</article>
@@ -123,6 +129,14 @@ function step(delta) {
}
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;
line-height: 1.3;
cursor: pointer;
@@ -143,6 +157,7 @@ h2:hover {
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
min-height: calc(4 * 1.45em);
flex: 1;
color: #b9b9c6;
font-size: 0.85rem;

View 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>

View File

@@ -5,6 +5,7 @@
--text: #ececf1;
--muted: #8b8b99;
--accent: #e50914;
--sidebar: 310px;
color-scheme: dark;
}