This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -1,3 +1,4 @@
TMDB_TOKEN=
MINIMAX_KEY=
MTX_SENDEPASSWORT=
HOST_SCHLUESSEL=

View File

@@ -18,6 +18,37 @@ cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen
make install
```
`HOST_SCHLUESSEL` in der `.env` ist frei wählbar, am besten lang und
zufällig:
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
```
Ohne Eintrag bleiben die Hostknöpfe für alle zu.
## Namen und Host
Der Host pflegt in der Verwaltung die Namensliste, alle anderen wählen sich
einen davon aus. Wer keinen Namen wählt, ist Zuschauer: lesen ja, eintragen
nein — keine Vorschläge, keine Sammlung, keine Wertungen, keine Wünsche.
**Anmeldefilm statt Passwort.** In der Verwaltung hinterlegt jeder für
seinen Namen einen Film. Wer den Namen danach wählt, bekommt die
Filmsuche vorgesetzt und muss den richtigen anklicken. Der Server gibt den
Film nie ungefragt heraus; über das Augensymbol sieht ihn nur der Host und
der Name selbst. Ein Name ohne Film bleibt für alle offen.
**Hostmodus.** Einmal `https://…/?host=<HOST_SCHLUESSEL>` aufrufen, dann
merkt sich die Sitzung das und der Schlüssel fliegt aus der Adresszeile.
Danach blendet `Strg+Shift+Alt+S` die Hostknöpfe ein und aus — nützlich,
um die Gastansicht zu prüfen. Ohne freigeschaltete Sitzung tut die
Tastenkombination nichts.
Der Host überspringt den Anmeldefilm und wechselt die Namen frei. Wer den
Link kennt, kann das also auch — für einen Filmabend unter Freunden reicht
das.
## Sync
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige

View File

@@ -9,6 +9,11 @@ load_dotenv(ROOT / ".env")
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
# Wer den Hostmodus einschalten darf. Einmal /?host=<schlüssel> aufrufen,
# danach steckt es in der Sitzung. Ohne Eintrag kommt niemand an die
# Hostknöpfe — auch nicht über die Tastenkombination.
HOST_SCHLUESSEL = os.getenv("HOST_SCHLUESSEL", "")
TMDB_BASE = "https://api.themoviedb.org/3"
IMAGE_BASE = "https://image.tmdb.org/t/p"

View File

@@ -116,10 +116,25 @@ def existing_netflix_ids(conn):
return {row["id"]: row["netflix_id"] for row in rows}
# Sammlung, Rad und Gesehen zeigen auch Filme, die nie auf Netflix liefen.
# Deren Daten stehen trotzdem im Katalog — sonst blieben die Karten leer.
BENUTZT = ("wishlist", "suggestions", "watched")
def benutzte_ids(conn):
"""Alle movie_ids, an denen jemand hängt."""
teile = " UNION ".join(f"SELECT movie_id FROM {t}" for t in BENUTZT)
return {r[0] for r in conn.execute(teile)}
def drop_missing(conn, keep_ids):
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus."""
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus.
Ausgenommen, was jemand gemerkt, vorgeschlagen oder geschaut hat: die
Karten dazu holen ihre Daten aus dieser Tabelle.
"""
current = {r["id"] for r in conn.execute("SELECT id FROM movies").fetchall()}
gone = current - set(keep_ids)
gone = current - set(keep_ids) - benutzte_ids(conn)
if gone:
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
return len(gone)

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -22,7 +23,7 @@ from . import (
watched,
wishlist,
)
from .config import IMAGE_BASE, STATIC_DIR
from .config import HOST_SCHLUESSEL, IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
@@ -93,12 +94,19 @@ async def startup():
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
if not HOST_SCHLUESSEL:
log.warning(
"HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu."
)
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
asyncio.create_task(guarded_sync())
else:
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
asyncio.create_task(sync.katalog_nachtragen())
@app.on_event("shutdown")
@@ -364,14 +372,21 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
async def film_holen(movie_id: int):
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB.
Was von TMDB kommt, landet gleich im Katalog. Sonst blieben die Karten
in Sammlung, Rad und Gesehen leer: die lesen ihre Daten von dort.
"""
movie = db.get_movie(movie_id)
if movie:
return movie
try:
return await search.detail(movie_id)
movie = await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
with db.connect() as conn:
db.upsert(conn, movie)
return movie
@app.get("/api/features")
@@ -493,7 +508,11 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"items": users.alle(), "mine": users.aktuell(sid)}
return {
"items": users.alle(),
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@app.post("/api/users")
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
return {"removed": 1}
@app.get("/api/users/{user_id}/schutz")
async def schutz_zeigen(
user_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Welcher Film hinterlegt ist — nur für den Host und den Namen selbst.
Wer ihn ändern darf, darf ihn auch sehen: sonst müsste man raten, was
man gerade überschreibt.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
movie_id = users.schutz_von(user_id)
if movie_id is None:
return {"film": None}
film = await film_holen(movie_id)
return {
"film": {
"id": film["id"],
"title": film["title"],
"year": film.get("year"),
"poster_path": film.get("poster_path"),
}
}
@app.post("/api/users/{user_id}/schutz")
def schutz_setzen(
user_id: int,
response: Response,
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Anmeldefilm hinterlegen — oder mit movie_id null wieder freigeben.
Der Host pflegt die Runde, sonst darf jeder nur den eigenen Namen
ändern. Wer angemeldet ist, hat den alten Film ohnehin gewusst.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
if not users.schutz_setzen(user_id, movie_id):
raise HTTPException(status_code=404, detail="Unbekannter Name")
events.senden("users")
return {"geschuetzt": movie_id is not None}
@app.get("/api/host")
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"host": users.ist_host(sid)}
@app.post("/api/host")
def host_modus(
response: Response,
an: bool = Body(..., embed=True),
schluessel: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
diese Route nicht zuständig.
"""
sid = sitzung(response, filmabend_sid)
if not an:
return {"host": users.host_setzen(sid, False)}
darf = users.ist_host(sid) or (
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@app.post("/api/users/waehlen")
def name_waehlen(
response: Response,
user_id: int | None = Body(None, embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Wer bin ich? Ohne user_id wieder anonym."""
"""Wer bin ich? Ohne user_id wieder anonym.
Hat der Name einen Anmeldefilm, muss movie_id ihn treffen. Der Host
und wer schon angemeldet ist, kommen ohne durch.
"""
sid = sitzung(response, filmabend_sid)
if user_id is None:
users.abwaehlen(sid)
return {"mine": None}
film = users.schutz_von(user_id)
frei = (
film is None
or users.ist_host(sid)
or users.besitzer(sid) == user_id
)
if not frei and movie_id != film:
raise HTTPException(status_code=403, detail="Das ist nicht der Film.")
benutzer = users.waehlen(sid, user_id)
if not benutzer:
raise HTTPException(status_code=404, detail="Unbekannter Name")
@@ -576,14 +694,13 @@ def bewerten(
watched_id: int,
response: Response,
stars: int = Body(..., embed=True),
text: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text)
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
if not eintrag:
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
events.senden("watched")
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
return {"removed": 1}
@app.post("/api/watched/{watched_id}/notes")
def gaestebuch_schreiben(
watched_id: int,
response: Response,
text: str = Body(..., embed=True),
parent_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen anderen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
note_id = watched.anmerken(watched_id, sid, text, parent_id)
if note_id is None:
raise HTTPException(status_code=400, detail="Kein Text oder Ziel unbekannt")
events.senden("watched")
return {"id": note_id}
# Eigener Pfad, damit er /api/watched/{watched_id} nicht in die Quere kommt
@app.delete("/api/watched-notes/{note_id}")
def gaestebuch_loeschen(
note_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Der Verfasser oder der Host — samt allen Antworten darunter."""
sid = sitzung(response, filmabend_sid)
entfernt = watched.anmerkung_loeschen(note_id, sid, users.ist_host(sid))
if not entfernt:
raise HTTPException(status_code=404, detail="Nicht gefunden oder nicht deins")
events.senden("watched")
return {"removed": entfernt}
@app.delete("/api/suggestions/alle")
def runde_zuruecksetzen():
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone
from urllib.parse import quote_plus
from . import canon, db, imagehash, netflix, teaser, tmdb
from . import canon, db, imagehash, netflix, search, teaser, tmdb
log = logging.getLogger("sync")
@@ -144,6 +144,40 @@ async def resolve_canon():
return places
async def katalog_nachtragen():
"""Filme in den Katalog holen, die jemand gemerkt hat, die aber weder
auf Netflix laufen noch im Kanon stehen.
Ohne sie bleiben die Karten in Sammlung, Rad und Gesehen leer: kein
Teaser, keine Note, keine Bilder. Die Detailansicht holt sich das
jedes Mal neu von TMDB — hier landet es einmal in der Datenbank.
"""
with db.connect() as conn:
fehlen = sorted(
db.benutzte_ids(conn)
- {r["id"] for r in conn.execute("SELECT id FROM movies")}
)
if not fehlen:
return 0
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
async def hole(movie_id):
async with bremse:
try:
return await search.detail(movie_id)
except Exception:
# Ein gelöschter TMDB-Eintrag soll den Rest nicht aufhalten
return None
filme = [f for f in await asyncio.gather(*map(hole, fehlen)) if f]
with db.connect() as conn:
for film in filme:
db.upsert(conn, film)
log.info("%s Filme aus Sammlungen in den Katalog geholt", len(filme))
return len(filme)
async def run():
log.info("Sync startet")
canon_places = await resolve_canon()
@@ -180,15 +214,18 @@ async def run():
db.set_meta(conn, "movie_count", len(on_netflix))
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
nachgetragen = await katalog_nachtragen()
log.info(
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
"%s Netflix-Links, %s neue Teaser, %s entfernt",
"%s Netflix-Links, %s neue Teaser, %s entfernt, %s nachgetragen",
len(movies),
len(on_netflix),
sum(1 for m in movies if m["canon_group"] is not None),
linked,
new_teasers,
removed,
nachgetragen,
)
return {
"movies": len(movies),
@@ -196,6 +233,7 @@ async def run():
"netflix_links": linked,
"new_teasers": new_teasers,
"removed": removed,
"nachgetragen": nachgetragen,
}

View File

@@ -1,8 +1,12 @@
"""Die Runde: wer mitmacht, und wer gerade wer ist.
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
Kein Passwort — für acht Freunde, die sich ohnehin über Discord hören, wäre
das nur eine Hürde ohne Nutzen.
Wer nicht will, dass ein anderer in seinem Namen bewertet, hinterlegt in
der Verwaltung einen Anmeldefilm. Dann muss man ihn beim Wechsel erst in
der Suche finden und anklicken. Kein Passwort im Klartext, nichts zu
merken außer einem Film — und für einen Filmabend unter Freunden genau die
richtige Hürde. Wer keinen Film hinterlegt, ist weiter offen für alle.
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
@@ -26,18 +30,37 @@ CREATE TABLE IF NOT EXISTS user_sessions (
user_id INTEGER NOT NULL,
seit TEXT NOT NULL
);
-- Wer den Hostmodus eingeschaltet hat. Der Host führt durch den Abend und
-- wechselt die Namen frei, ohne jedes Mal den Anmeldefilm zu suchen.
CREATE TABLE IF NOT EXISTS host_sessions (
session_id TEXT PRIMARY KEY,
seit TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
# Der Anmeldefilm kam später dazu
spalten = {r[1] for r in conn.execute("PRAGMA table_info(users)")}
if "auth_movie_id" not in spalten:
conn.execute("ALTER TABLE users ADD COLUMN auth_movie_id INTEGER")
def _oeffentlich(row):
"""Der hinterlegte Film verlässt den Server nie — sonst stünde das
Passwort im Netzwerk-Tab. Nach draußen geht nur, ob einer gesetzt ist."""
benutzer = dict(row)
benutzer["geschuetzt"] = benutzer.pop("auth_movie_id", None) is not None
return benutzer
def alle():
with connect() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
return [dict(r) for r in rows]
return [_oeffentlich(r) for r in rows]
def anlegen(name):
@@ -59,7 +82,24 @@ def anlegen(name):
def von_name(name):
with connect() as conn:
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def schutz_setzen(user_id, movie_id):
"""Anmeldefilm hinterlegen oder mit None wieder freigeben."""
with connect() as conn:
return conn.execute(
"UPDATE users SET auth_movie_id = ? WHERE id = ?", (movie_id, user_id)
).rowcount
def schutz_von(user_id):
"""Die hinterlegte Film-Kennung, oder None bei offenem Namen."""
with connect() as conn:
row = conn.execute(
"SELECT auth_movie_id FROM users WHERE id = ?", (user_id,)
).fetchone()
return row["auth_movie_id"] if row else None
def entfernen(user_id):
@@ -84,7 +124,7 @@ def waehlen(session_id, user_id):
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
return dict(da)
return _oeffentlich(da)
def abwaehlen(session_id):
@@ -103,7 +143,32 @@ def aktuell(session_id):
"WHERE s.session_id = ?",
(session_id,),
).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def ist_host(session_id):
with connect() as conn:
return bool(
conn.execute(
"SELECT 1 FROM host_sessions WHERE session_id = ?", (session_id,)
).fetchone()
)
def host_setzen(session_id, an):
"""Den Hostmodus merkt sich auch der Server — sonst könnte er den
Anmeldefilm nicht überspringen."""
with connect() as conn:
if an:
conn.execute(
"INSERT OR REPLACE INTO host_sessions (session_id, seit) VALUES (?, ?)",
(session_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
else:
conn.execute(
"DELETE FROM host_sessions WHERE session_id = ?", (session_id,)
)
return an
def besitzer(session_id):

View File

@@ -1,13 +1,16 @@
"""Bereits geschaute Filme und ihre Bewertungen.
"""Bereits geschaute Filme — das Buch der Abende.
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
freiwillig, ohne ihn zählt die Stimme trotzdem.
Der Host trägt ein, was am Abend lief. Jeder Film bekommt eine Seite mit
zwei Teilen. Links die Momentaufnahme: eine Wertung je Person, nur Sterne,
so wie es direkt nach dem Abspann war. Rechts die Diskussion: Kommentare,
auf die man antworten kann, so tief man will.
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
auch wenn der Film später aus dem Katalog fällt.
auch wenn der Film später aus dem Katalog fällt. Szenenbild und Laufzeit
kommen aus dem Katalog.
"""
import json
from datetime import datetime, timezone
from . import users
@@ -39,16 +42,54 @@ CREATE TABLE IF NOT EXISTS ratings (
);
"""
# Die Diskussion: ein Kommentar zum Abend, oder mit parent_id eine
# Antwort auf einen anderen Kommentar — beliebig tief
NOTES = """
CREATE TABLE IF NOT EXISTS watched_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
parent_id INTEGER,
session_id TEXT NOT NULL,
user_id INTEGER,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_TEXT = 4000
MAX_KOMMENTAR = 4000
# Was das Gästebuch aus dem Katalog dazuholt
AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
def init():
with connect() as conn:
conn.executescript(SCHEMA + RATINGS)
conn.executescript(SCHEMA + RATINGS + NOTES)
# Eine Stimme je Person und Film
users.je_person(conn, "ratings", RATINGS, "watched_id")
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
conn.execute("DROP TABLE IF EXISTS watched_hearts")
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched_notes)")}
if "rating_id" in spalten:
conn.execute("ALTER TABLE watched_notes RENAME COLUMN rating_id TO parent_id")
# Eine Weile stand ein Text an der Wertung. Seit die Sterne allein
# die Wertung sind, gehört er in die Diskussion — einmalig umziehen.
alte = conn.execute(
"SELECT * FROM ratings WHERE text IS NOT NULL AND text != ''"
).fetchall()
for r in alte:
conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, NULL, ?, ?, ?, ?)",
(r["watched_id"], r["session_id"], r["user_id"], r["text"], r["created_at"]),
)
if alte:
conn.execute("UPDATE ratings SET text = NULL")
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
if "hidden" not in spalten:
@@ -66,43 +107,80 @@ def _jetzt():
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
filme = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
f"SELECT w.*, {felder} FROM watched w "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.watched_at DESC, w.id DESC"
).fetchall()
stimmen = conn.execute(
"SELECT id, watched_id, session_id, user_id, stars, name, text "
"SELECT id, watched_id, session_id, user_id, stars, name, created_at "
"FROM ratings ORDER BY created_at ASC"
).fetchall()
notizen = conn.execute(
"SELECT n.*, u.name FROM watched_notes n "
"LEFT JOIN users u ON u.id = n.user_id "
"ORDER BY n.created_at ASC"
).fetchall()
ich = besitzer(session_id)
def notiz(n):
return {
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
}
# Die Kommentare eines Films als Baum: jede Antwort hängt sich an
# ihren Vater, was keinen hat, steht oben. Die Liste kommt nach
# Zeit sortiert, also ist der Vater immer schon da.
knoten = {n["id"]: notiz(n) for n in notizen}
kommentare = {}
for n in notizen:
k = knoten[n["id"]]
vater = knoten.get(n["parent_id"]) if n["parent_id"] is not None else None
if vater:
vater["replies"].append(k)
else:
kommentare.setdefault(n["watched_id"], []).append(k)
je_film = {}
for s in stimmen:
je_film.setdefault(s["watched_id"], []).append(s)
out = []
for film in filme:
for nummer, film in zip(range(len(filme), 0, -1), filme):
eigene = je_film.get(film["id"], [])
werte = [s["stars"] for s in eigene]
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
daten = dict(film)
daten["images"] = json.loads(daten["images"]) if daten["images"] else []
daten["genres"] = json.loads(daten["genres"]) if daten["genres"] else []
out.append(
{
**dict(film),
**daten,
"nummer": nummer,
"ratings": [
{
"id": s["id"],
"stars": s["stars"],
"name": s["name"],
"text": s["text"],
"created_at": s["created_at"],
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
],
"notes": kommentare.get(film["id"], []),
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
"mine": meine["stars"] if meine else None,
"mine_text": meine["text"] if meine else None,
}
)
return out
@@ -130,7 +208,9 @@ def markieren(movie):
def entfernen(watched_id):
"""Samt Wertungen und Kommentaren."""
with connect() as conn:
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
return conn.execute(
"DELETE FROM watched WHERE id = ?", (watched_id,)
@@ -156,15 +236,9 @@ def sichtbarkeit(watched_id, versteckt):
).rowcount
def bewerten(watched_id, session_id, stars, name=None, text=None):
"""Sterne und ein optionaler Satz dazu.
Kommt kein Text mit (None), bleibt der bisherige stehen — wer nur die
Sterne nachjustiert, verliert seinen Satz nicht. Ein leerer String
löscht ihn ausdrücklich.
Gibt None zurück, wenn der Film gar nicht in der Liste steht.
"""
def bewerten(watched_id, session_id, stars, name=None):
"""Sterne setzen. Gibt None zurück, wenn der Film nicht in der Liste
steht."""
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
@@ -172,14 +246,6 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
if not da:
return None
ich = besitzer(session_id)
if text is None:
alt = conn.execute(
f"SELECT text FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id),
).fetchone()
text = alt["text"] if alt else None
else:
text = text.strip()[:MAX_TEXT] or None
# Eine Stimme je Person — die eigene alte fliegt vorher raus
conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
@@ -187,19 +253,11 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, user_id, stars, name, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
watched_id,
session_id,
ich,
stars,
(name or "").strip()[:40] or None,
text,
_jetzt(),
),
"(watched_id, session_id, user_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
)
return {"watched_id": watched_id, "stars": stars, "text": text}
return {"watched_id": watched_id, "stars": stars}
def stimme_loeschen(rating_id):
@@ -210,6 +268,70 @@ def stimme_loeschen(rating_id):
).rowcount
def anmerken(watched_id, session_id, text, parent_id=None):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen
anderen. Gibt None zurück, wenn Film oder Vater fehlen."""
text = (text or "").strip()[:MAX_KOMMENTAR]
if not text:
return None
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone()
if not da:
return None
if parent_id is not None:
passt = conn.execute(
"SELECT 1 FROM watched_notes WHERE id = ? AND watched_id = ?",
(parent_id, watched_id),
).fetchone()
if not passt:
return None
cur = conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, parent_id, session_id, besitzer(session_id), text, _jetzt()),
)
return cur.lastrowid
def _nachkommen(conn, note_id):
"""Alle Antworten unter einem Kommentar, beliebig tief."""
gefunden, offen = [], [note_id]
while offen:
kinder = [
r["id"]
for r in conn.execute(
f"SELECT id FROM watched_notes WHERE parent_id IN "
f"({','.join('?' * len(offen))})",
offen,
)
]
gefunden.extend(kinder)
offen = kinder
return gefunden
def anmerkung_loeschen(note_id, session_id, host):
"""Der Verfasser und der Host dürfen weg damit — samt allem, was
darunter hängt. Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
ich = besitzer(session_id)
with connect() as conn:
zeile = conn.execute(
"SELECT * FROM watched_notes WHERE id = ?", (note_id,)
).fetchone()
if not zeile:
return 0
if not host and not gehoert_mir(zeile, session_id, ich):
return 0
alle_ids = [note_id, *_nachkommen(conn, note_id)]
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

View File

@@ -37,13 +37,20 @@ def init():
users.je_person(conn, "wishlist", SCHEMA, "movie_id")
# Was die Karte in der Detailansicht zeigt. Steht im Katalog, nicht in der
# Sammlung — ein Film, der nie im Katalog war, bleibt hier leer.
AUS_KATALOG = ("genres", "teaser", "overview", "runtime", "vote_average",
"vote_count", "images")
def alle(session_id):
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
rows = conn.execute(
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
"SELECT w.*, u.name, m.genres FROM wishlist w "
f"SELECT w.*, u.name, {felder} FROM wishlist w "
"LEFT JOIN users u ON u.id = w.user_id "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.created_at DESC"
@@ -54,9 +61,10 @@ def alle(session_id):
**{
k: v
for k, v in dict(r).items()
if k not in ("session_id", "user_id", "genres")
if k not in ("session_id", "user_id", "genres", "images")
},
"genres": json.loads(r["genres"]) if r["genres"] else [],
"images": json.loads(r["images"]) if r["images"] else [],
"mine": gehoert_mir(r, session_id, ich),
}
for r in rows

Binary file not shown.

View File

@@ -12,6 +12,8 @@ _ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
os.environ.setdefault("TMDB_TOKEN", "test")
os.environ.setdefault("MINIMAX_KEY", "test")
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@@ -85,6 +87,21 @@ def zuschauer():
return TestClient(main.app)
HOST_SCHLUESSEL = "test-host-schluessel"
@pytest.fixture
def hostmodus():
"""Schaltet einen Client per Hostschlüssel frei."""
def an(client):
return client.post(
"/api/host", json={"an": True, "schluessel": HOST_SCHLUESSEL}
)
return an
@pytest.fixture
def film():
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""

View File

@@ -0,0 +1,156 @@
"""Das Buch der Abende: Wertungen als Momentaufnahme, Kommentare als
Diskussion — verschachtelt, so tief man will."""
def abend(client, film, movie_id=1):
"""Host trägt einen Film ein, gibt die watched_id zurück."""
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
def wertung(client, watched_id, stars=4):
client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars})
film = next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
return next(r for r in film["ratings"] if r["mine"])
def film_von(client, watched_id):
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
def kommentar(client, watched_id, text, parent_id=None):
antwort = client.post(
f"/api/watched/{watched_id}/notes", json={"text": text, "parent_id": parent_id}
)
assert antwort.status_code == 200, antwort.text
return antwort.json()["id"]
def test_laufende_nummer_zaehlt_vom_ersten_abend(client, film):
erster = abend(client, film, 1)
client.patch(f"/api/watched/{erster}", json={"watched_at": "2026-01-01"})
zweiter = abend(client, film, 2)
client.patch(f"/api/watched/{zweiter}", json={"watched_at": "2026-01-08"})
liste = client.get("/api/watched").json()
# Jüngster zuerst, trägt aber die höchste Nummer
assert [(f["id"], f["nummer"]) for f in liste] == [(zweiter, 2), (erster, 1)]
def test_katalogdaten_haengen_am_abend(client):
from app import db
with db.connect() as conn:
db.upsert(conn, {"id": 5, "title": "Alien", "backdrop_path": "/b.jpg",
"images": ["/s1.jpg"], "runtime": 117, "genres": ["Horror"]})
watched_id = client.post("/api/watched", json={"movie_id": 5}).json()["id"]
f = film_von(client, watched_id)
assert f["backdrop_path"] == "/b.jpg"
assert f["images"] == ["/s1.jpg"]
assert f["runtime"] == 117
assert f["genres"] == ["Horror"]
def test_kommentare_bilden_einen_baum(client, client2, film):
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "War super.")
antwort = kommentar(client2, watched_id, "Fand ich nicht.", oben)
tiefer = kommentar(client, watched_id, "Doch!", antwort)
noch_einer = kommentar(client2, watched_id, "Anderes Thema.")
notes = film_von(client, watched_id)["notes"]
assert [n["text"] for n in notes] == ["War super.", "Anderes Thema."]
assert notes[0]["replies"][0]["text"] == "Fand ich nicht."
assert notes[0]["replies"][0]["name"] == "Ben"
assert notes[0]["replies"][0]["mine"] is False
assert notes[0]["replies"][0]["replies"][0]["text"] == "Doch!"
assert notes[0]["replies"][0]["replies"][0]["mine"] is True
assert notes[1]["replies"] == []
assert (oben, antwort, tiefer, noch_einer) == tuple(sorted((oben, antwort, tiefer, noch_einer)))
def test_zehn_ebenen_tief(client, film):
watched_id = abend(client, film)
vater = None
for stufe in range(10):
vater = kommentar(client, watched_id, f"Stufe {stufe}", vater)
knoten = film_von(client, watched_id)["notes"][0]
tiefe = 0
while knoten["replies"]:
knoten = knoten["replies"][0]
tiefe += 1
assert tiefe == 9
assert knoten["text"] == "Stufe 9"
def test_leerer_text_oder_fremder_vater_werden_abgelehnt(client, film):
watched_id = abend(client, film, 1)
anderer = abend(client, film, 2)
dort = kommentar(client, anderer, "woanders")
assert client.post(f"/api/watched/{watched_id}/notes", json={"text": " "}).status_code == 400
# Der Vater gehört zu einem anderen Abend
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": dort}
).status_code == 400
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": 9999}
).status_code == 400
def test_wertungen_und_kommentare_sind_unabhaengig(client, film):
watched_id = abend(client, film)
wertung(client, watched_id, 5)
kommentar(client, watched_id, "Diskussion.")
f = film_von(client, watched_id)
assert f["ratings"][0]["stars"] == 5
assert "text" not in f["ratings"][0]
assert [n["text"] for n in f["notes"]] == ["Diskussion."]
def test_eigenen_kommentar_loeschen_fremden_nicht(client, client2, film):
watched_id = abend(client, film)
meiner = kommentar(client, watched_id, "meiner")
assert client2.delete(f"/api/watched-notes/{meiner}").status_code == 404
assert client.delete(f"/api/watched-notes/{meiner}").json()["removed"] == 1
assert film_von(client, watched_id)["notes"] == []
def test_loeschen_nimmt_den_ganzen_ast_mit(client, client2, film):
"""Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "a")
mitte = kommentar(client2, watched_id, "b", oben)
unten = kommentar(client, watched_id, "c", mitte)
daneben = kommentar(client2, watched_id, "d")
assert client.delete(f"/api/watched-notes/{oben}").json()["removed"] == 3
notes = film_von(client, watched_id)["notes"]
assert [n["id"] for n in notes] == [daneben]
def test_host_loescht_jeden_kommentar(client, zuschauer, hostmodus, film):
watched_id = abend(client, film)
note_id = kommentar(client, watched_id, "x")
hostmodus(zuschauer)
assert zuschauer.delete(f"/api/watched-notes/{note_id}").json()["removed"] == 1
def test_film_loeschen_raeumt_alles(client, client2, film):
from app import db
watched_id = abend(client, film)
wertung(client, watched_id, 5)
oben = kommentar(client2, watched_id, "frei")
kommentar(client, watched_id, "antwort", oben)
client.delete(f"/api/watched/{watched_id}")
with db.connect() as conn:
for tabelle in ("ratings", "watched_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0, tabelle
def test_zuschauer_darf_nicht_schreiben(zuschauer, client, film):
watched_id = abend(client, film)
assert zuschauer.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).status_code == 403

185
backend/tests/test_login.py Normal file
View File

@@ -0,0 +1,185 @@
"""Anmelden per Film.
Wer einen Anmeldefilm hinterlegt hat, muss ihn beim Namenswechsel in der
Suche finden. Ohne Film bleibt der Name offen für alle.
"""
def namen(client, name="Anna"):
"""Legt den Namen an und gibt seine Kennung zurück."""
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
return antwort.json()["id"]
runde = client.get("/api/users").json()["items"]
return next(u["id"] for u in runde if u["name"] == name)
def test_ohne_film_bleibt_der_name_offen(client2, film):
user_id = namen(client2, "Offen")
antwort = client2.post("/api/users/waehlen", json={"user_id": user_id})
assert antwort.status_code == 200
assert antwort.json()["mine"]["geschuetzt"] is False
def test_falscher_film_kommt_nicht_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Ben kennt den Film nicht
assert (
client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": film(7)}
).status_code
== 403
)
# Ganz ohne Film auch nicht
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 403
)
assert client2.get("/api/users").json()["mine"]["name"] == "Ben"
def test_richtiger_film_laesst_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
antwort = client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": 42}
)
assert antwort.status_code == 200
assert antwort.json()["mine"]["name"] == "Anna"
assert client2.get("/api/users").json()["mine"]["name"] == "Anna"
def test_der_film_verlaesst_den_server_nicht(client, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
runde = client.get("/api/users").json()
anna = next(u for u in runde["items"] if u["id"] == user_id)
assert anna["geschuetzt"] is True
assert "auth_movie_id" not in anna
assert "auth_movie_id" not in runde["mine"]
def test_aufheben_macht_den_namen_wieder_frei(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": None})
assert client.get("/api/users").json()["items"][0]["geschuetzt"] is False
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_host_braucht_den_film_nicht(client, zuschauer, film, hostmodus):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
hostmodus(zuschauer)
assert zuschauer.get("/api/users").json()["host"] is True
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 200
)
# Hostmodus aus: die Hürde steht wieder
zuschauer.post("/api/users/waehlen", json={"user_id": None})
zuschauer.post("/api/host", json={"an": False})
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 403
)
def test_wer_schon_angemeldet_ist_muss_nicht_nochmal(client, film):
user_id = namen(client, "Anna")
client.post("/api/users/waehlen", json={"user_id": user_id})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Derselbe Name in derselben Sitzung, etwa nach einem Neuladen
assert (
client.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_fremden_schutz_darf_man_nicht_setzen(client, client2, film):
anna = namen(client, "Anna")
assert (
client2.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 403
)
assert client2.get("/api/users").json()["items"][0]["geschuetzt"] is False
def test_eigenen_schutz_darf_man_setzen(client, film):
"""client ist als Anna angemeldet und ändert ihren eigenen Film."""
anna = client.get("/api/users").json()["mine"]["id"]
assert (
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 200
)
def test_hinterlegten_film_darf_man_nachschlagen(client, film):
"""Wer ihn ändern darf, darf ihn auch sehen — sonst rät man beim
Überschreiben."""
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
antwort = client.get(f"/api/users/{anna}/schutz")
assert antwort.status_code == 200
assert antwort.json()["film"]["title"] == "Alien"
def test_ohne_film_kommt_nichts_zurueck(client):
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get(f"/api/users/{anna}/schutz").json()["film"] is None
def test_fremden_film_darf_man_nicht_nachschlagen(client, client2, film):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)})
assert client2.get(f"/api/users/{anna}/schutz").status_code == 403
def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
hostmodus(zuschauer)
assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien"
def test_ohne_schluessel_kein_hostmodus(zuschauer):
"""Die Tastenkombination allein reicht nicht mehr."""
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_falscher_schluessel_kein_hostmodus(zuschauer):
antwort = zuschauer.post(
"/api/host", json={"an": True, "schluessel": "geraten"}
)
assert antwort.status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus):
assert hostmodus(zuschauer).json()["host"] is True
assert zuschauer.get("/api/host").json()["host"] is True
# Die Freischaltung hält, ein zweiter Aufruf braucht den Schlüssel nicht
assert zuschauer.post("/api/host", json={"an": True}).status_code == 200
assert zuschauer.get("/api/host").json()["host"] is True
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus):
hostmodus(zuschauer)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# Jetzt ist die Sitzung wieder gewöhnlich
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus):
hostmodus(zuschauer)
assert client2.get("/api/host").json()["host"] is False

View File

@@ -1,6 +1,5 @@
"""Wertungen: Sterne, Text, Schnitt."""
from app.watched import MAX_TEXT
def gesehen(client, film, movie_id=1):
@@ -9,42 +8,47 @@ def gesehen(client, film, movie_id=1):
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
def test_wertung_mit_text(client, film):
wid = gesehen(client, film)
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"}
[eintrag] = client.get("/api/watched").json()
def test_wertung_ist_nur_sterne(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
assert antwort.json() == {"watched_id": wid, "stars": 4}
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 4
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert eintrag["mine"] == 4
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["ratings"][0]["mine"] is True
assert "text" not in eintrag["ratings"][0]
assert "mine_text" not in eintrag
def test_text_none_behaelt_leer_loescht(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
# Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme
client.post(f"/api/watched/{wid}/rating", json={"stars": 3})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine"] == 3
assert eintrag["mine_text"] == "Gruselig"
def test_neue_sterne_ersetzen_die_alten(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
client.post(f"/api/watched/{wid}/rating", json={"stars": 2})
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 2
assert eintrag["count"] == 1
client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine_text"] is None
def test_alter_wertungstext_zieht_in_die_diskussion(client, film):
"""Vor dem Umbau hing ein Text an den Sternen. Beim Start wandert er
als Kommentar in die Diskussion, mit Verfasser und Datum."""
from app import db, watched
def test_text_wird_gekuerzt(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)})
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 5})
with db.connect() as conn:
conn.execute("UPDATE ratings SET text = 'Alte Rezension'")
watched.init()
[eintrag] = client.get("/api/watched").json()
assert len(eintrag["mine_text"]) == MAX_TEXT
f = client.get("/api/watched").json()[0]
assert [n["text"] for n in f["notes"]] == ["Alte Rezension"]
assert f["notes"][0]["name"] == "Anna"
assert f["notes"][0]["mine"] is True
with db.connect() as conn:
assert conn.execute("SELECT COUNT(*) FROM ratings WHERE text IS NOT NULL").fetchone()[0] == 0
# Ein zweiter Start zieht nichts doppelt um
watched.init()
assert len(client.get("/api/watched").json()[0]["notes"]) == 1
def test_fremde_wertung_ist_nicht_meine(client, client2, film):

View File

@@ -0,0 +1,110 @@
"""Die Sammlungen.
Alle sehen alle, ändern darf jeder nur die eigene. Die Karten im
Sammlungen-Reiter zeigen dieselben Daten wie im Katalog — die holt die
Liste per Join nach.
"""
from app import db
def katalogfilm(movie_id, **felder):
"""Film mit allem, was die Detailkarte zeigt."""
with db.connect() as conn:
db.upsert(
conn,
{
"id": movie_id,
"title": "Alien",
"year": 1979,
"teaser": "Im Weltall hört dich niemand schreien.",
"overview": "Die Nostromo fängt ein Signal auf.",
"runtime": 117,
"vote_average": 8.2,
"vote_count": 14000,
"genres": ["Horror", "Science Fiction"],
"images": ["/a.jpg", "/b.jpg"],
**felder,
},
)
return movie_id
def test_karte_bekommt_die_katalogdaten(client):
client.post("/api/wishlist", json={"movie_id": katalogfilm(42)})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["teaser"].startswith("Im Weltall")
assert eintrag["runtime"] == 117
assert eintrag["vote_average"] == 8.2
assert eintrag["vote_count"] == 14000
assert eintrag["genres"] == ["Horror", "Science Fiction"]
assert eintrag["images"] == ["/a.jpg", "/b.jpg"]
def test_film_ausserhalb_des_katalogs_bleibt_spaerlich(client, film):
"""Titel und Poster stehen in der Sammlung selbst, der Rest fehlt."""
client.post("/api/wishlist", json={"movie_id": film(7, "Irgendwas")})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["title"] == "Irgendwas"
assert eintrag["teaser"] is None
assert eintrag["genres"] == []
assert eintrag["images"] == []
def test_merken_und_wieder_vergessen(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
assert client.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 1
assert client.get("/api/wishlist").json() == []
def test_zweimal_merken_legt_nur_einen_an(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
def test_fremde_sammlung_sieht_man_aber_raeumt_sie_nicht(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
# Ben sieht Annas Eintrag, aber er gehört ihm nicht
eintrag = client2.get("/api/wishlist").json()[0]
assert eintrag["name"] == "Anna"
assert eintrag["mine"] is False
# Sein Löschversuch trifft nur die eigene Sammlung, also nichts
assert client2.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 0
assert len(client.get("/api/wishlist").json()) == 1
def test_jeder_hat_seine_eigene_sammlung(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client2.post("/api/wishlist", json={"movie_id": movie_id})
eintraege = client.get("/api/wishlist").json()
assert len(eintraege) == 2
assert sorted(e["name"] for e in eintraege) == ["Anna", "Ben"]
def test_sync_verschont_was_jemand_gemerkt_hat(client):
"""Der Katalog kennt nur Netflix und den Kanon. Gemerkte Filme müssen
trotzdem drinbleiben — sonst verlieren ihre Karten alle Daten."""
gemerkt = katalogfilm(42)
egal = katalogfilm(99, title="Niemandsfilm")
client.post("/api/wishlist", json={"movie_id": gemerkt})
with db.connect() as conn:
# Der Sync behält nur, was er gerade geholt hat — hier gar nichts
weg = db.drop_missing(conn, [])
uebrig = {r["id"] for r in conn.execute("SELECT id FROM movies")}
assert weg == 1
assert uebrig == {gemerkt}
assert egal not in uebrig

View File

@@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue'
import VerwaltungTab from './components/VerwaltungTab.vue'
@@ -29,6 +30,11 @@ const {
wuensche, ladeWuensche,
} = useMeins({ host })
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die
// Reiterliste fragt danach.
const angemeldet = computed(() => !!ich.value)
const {
movies, loading, error, load,
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
@@ -84,16 +90,42 @@ const tabs = computed(() => [
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
...(host.value || angemeldet.value
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
: []),
])
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
// legt sie sich über einen Knopf über die Seite
const filterOffen = ref(false)
const mitFilter = computed(() =>
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
['netflix', 'canon', 'search', 'aehnlich', 'merkliste'].includes(activeTab.value)
)
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
// dafür hat er eine eigene Leiste
const sammlungLeute = ref([])
const sammlungHorror = ref(null)
// Wer überhaupt etwas gesammelt hat, mit Anzahl. Die eigene zuerst.
const leuteMitSammlung = computed(() => {
const nach = new Map()
for (const eintrag of merkliste.value) {
const name = eintrag.name || 'Anonym'
if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine })
nach.get(name).anzahl += 1
}
return [...nach.values()].sort(
(a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de')
)
})
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
// sonst keiner markiert und die Seite bliebe leer
watch(tabs, (liste) => {
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix'
})
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
const reiter = ref(null)
@@ -105,10 +137,6 @@ watch(activeTab, async () => {
leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' })
})
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen
const angemeldet = computed(() => !!ich.value)
const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
@@ -193,7 +221,12 @@ onMounted(load)
Filter
</button>
<span v-if="host" class="host">Host</span>
<NamensWahl :items="runde" :mine="ich" @waehlen="michSetzen" />
<NamensWahl
:items="runde"
:mine="ich"
:host="host"
:anmelden="michSetzen"
/>
</div>
</header>
@@ -256,15 +289,27 @@ onMounted(load)
@changed="ladeGesehen"
/>
<MerklisteTab
v-else-if="activeTab === 'merkliste'"
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
@open="selected = $event"
@changed="ladeMerkliste"
/>
<div v-else-if="activeTab === 'merkliste'" class="layout">
<MerklisteTab
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
:ansicht="ansicht"
:leute="sammlungLeute"
:horror="sammlungHorror"
@open="filmOeffnen"
@changed="ladeMerkliste"
/>
<SammlungFilter
v-model:gewaehlt="sammlungLeute"
v-model:horror="sammlungHorror"
v-model:ansicht="ansicht"
:class="{ offen: filterOffen }"
:leute="leuteMitSammlung"
/>
</div>
<WuenscheTab
v-else-if="activeTab === 'wuensche'"
@@ -275,7 +320,12 @@ onMounted(load)
@changed="ladeWuensche"
/>
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" />
<VerwaltungTab
v-else-if="activeTab === 'verwaltung'"
:items="runde"
:host="host"
:ich="ich"
/>
<StreamTab v-else-if="activeTab === 'stream'" />

View File

@@ -0,0 +1,631 @@
<script setup>
// Eine Doppelseite im Buch: ein Abend, ein Film. Links das Cover mit Titel,
// Datum, dem Urteil der Runde und den Sternen — die Momentaufnahme nach dem
// Abspann. Rechts das Gespräch danach: Rezensionen und Kommentare, mit
// eigenem Scrollen, das Cover bleibt stehen. Auf dem Handy liegen die
// Hälften untereinander.
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { rendern } from '../markdown'
import Kommentar from './Kommentar.vue'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
item: { type: Object, required: true },
host: Boolean,
angemeldet: Boolean,
})
const emit = defineEmits(['open'])
const STERNE = [1, 2, 3, 4, 5]
const fehler = ref('')
const cover = computed(() =>
props.item.poster_path ? posterUrl(props.item.poster_path, 'w780') : null
)
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
async function oeffnen() {
const resp = await fetch(`/api/search/${props.item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
// ── Links: die Momentaufnahme ──────────────────────────────────────────
const schwebt = ref(0)
function gefuellt(stern) {
return stern <= (schwebt.value || props.item.mine || 0)
}
async function bewerten(sterne) {
try {
await fetch(`/api/watched/${props.item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterne }),
})
} catch {
fehler.value = 'Bewerten hat nicht geklappt.'
}
}
// Die eigene zuerst
const wertungen = computed(() =>
[...props.item.ratings].sort((a, b) => b.mine - a.mine)
)
async function wertungEntfernen(wertung) {
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
}
// ── Rechts: Schreiben ──────────────────────────────────────────────────
// Unter der Diskussion klappt auf Klick ein Feld auf; Absenden klappt es zu
const schreibt = ref(false)
const kommentarEntwurf = ref('')
// ── Rechts: die Diskussion ─────────────────────────────────────────────
async function kommentieren() {
const text = kommentarEntwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.item.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
if (resp.ok) {
kommentarEntwurf.value = ''
schreibt.value = false
} else fehler.value = 'Schreiben hat nicht geklappt.'
}
function zaehlen(liste) {
return liste.reduce((n, k) => n + 1 + zaehlen(k.replies), 0)
}
const anzahl = computed(() => zaehlen(props.item.notes))
// ── Host ───────────────────────────────────────────────────────────────
function alsFeld(iso) {
const d = new Date(iso)
const zwei = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
async function datumSetzen(tag) {
if (!tag) return
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched_at: tag }),
})
}
async function sichtbarkeit() {
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !props.item.hidden }),
})
}
async function entfernen() {
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
}
</script>
<template>
<article class="doppelseite" :class="{ versteckt: item.hidden }">
<!-- ── Linke Seite: das Cover ─────────────────────────────────── -->
<section class="links">
<div class="cover" :style="cover ? { backgroundImage: `url(${cover})` } : null"></div>
<div class="momentaufnahme">
<header>
<p class="wann">
Abend {{ item.nummer }} · {{ datum(item.watched_at) }}
<span v-if="item.hidden" class="marke">ausgeblendet</span>
</p>
<h2 @click="oeffnen">{{ item.title }}</h2>
<p class="meta">
<span v-if="item.year">{{ item.year }}</span>
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
</p>
<p class="urteil">
<template v-if="item.count">
<span class="wert">{{ item.average.toFixed(1) }}</span>
<span class="von">von 5 · {{ item.count }} {{ item.count === 1 ? 'Stimme' : 'Stimmen' }}</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
</p>
</header>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
<span class="deine">Du:</span>
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebt = s"
@click="bewerten(s)"
>
★
</button>
</div>
<ul v-if="wertungen.length" class="stimmen">
<li v-for="r in wertungen" :key="r.id">
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<span class="wer" :class="{ eigen: r.mine }">{{ r.name || 'Anonym' }}</span>
<button
v-if="host"
type="button"
class="weg"
title="Wertung entfernen"
@click="wertungEntfernen(r)"
>
×
</button>
</li>
</ul>
<div v-if="host" class="host-zeile">
<label>
Geschaut am
<input type="date" :value="alsFeld(item.watched_at)" @change="datumSetzen($event.target.value)" />
</label>
<button type="button" class="klein" @click="sichtbarkeit">
{{ item.hidden ? 'Einblenden' : 'Ausblenden' }}
</button>
<button type="button" class="klein loeschen" @click="entfernen">Löschen</button>
</div>
</div>
</section>
<!-- ── Rechte Seite: das Gespräch ─────────────────────────────── -->
<section class="rechts">
<h3>
Diskussion
<span v-if="anzahl" class="anzahl">{{ anzahl }}</span>
</h3>
<p v-if="!anzahl" class="leer">Noch nichts gesagt.</p>
<ul v-else class="liste">
<Kommentar
v-for="k in item.notes"
:key="k.id"
:kommentar="k"
:watched-id="item.id"
:angemeldet="angemeldet"
:host="host"
/>
</ul>
<div v-if="angemeldet" class="schreiben">
<button v-if="!schreibt" type="button" class="schreib-knopf" @click="schreibt = true">
Schreiben
</button>
<MarkdownFeld
v-else
v-model="kommentarEntwurf"
platzhalter="Was sagst du dazu?"
knopf="Schreiben"
:maxlaenge="4000"
@senden="kommentieren"
@abbrechen="schreibt = false"
/>
</div>
</section>
</article>
</template>
<style scoped>
.doppelseite {
display: flex;
height: 100%;
background: var(--bg);
}
/* ── Links ─────────────────────────────────────────────────────────── */
.links {
position: relative;
display: flex;
flex: 0 0 50%;
flex-direction: column;
/* Die Momentaufnahme steht unten auf dem Cover */
justify-content: flex-end;
min-width: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.versteckt .cover {
opacity: 0.5;
}
.cover {
position: absolute;
top: 0;
right: 0;
left: 0;
aspect-ratio: 2 / 3;
background: var(--bg-soft) center / cover no-repeat;
}
/* Ab der Mitte wird das Cover dunkler, unten ist es der Hintergrund */
.cover::after {
position: absolute;
inset: 0;
content: '';
background: linear-gradient(
to bottom,
rgba(10, 10, 12, 0) 35%,
rgba(10, 10, 12, 0.75) 62%,
var(--bg) 100%
);
}
.momentaufnahme {
position: relative;
display: flex;
flex-direction: column;
gap: 0.9rem;
/* Der Text beginnt gut unterhalb der Covermitte */
padding: 70% 1.5rem 1.5rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.35rem;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.wann {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.marke {
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
}
h2 {
font-size: 2rem;
line-height: 1.1;
cursor: pointer;
}
h2:hover {
color: var(--accent);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--muted);
font-size: 0.88rem;
}
.urteil {
display: flex;
gap: 0.5rem;
align-items: baseline;
margin-top: 0.3rem;
}
.wert {
color: #f5c518;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.von {
color: var(--muted);
font-size: 0.88rem;
}
.wertung {
display: flex;
gap: 0.1rem;
align-items: center;
}
.wertung .link {
margin-left: 0.8rem;
}
.deine {
margin-right: 0.4rem;
color: var(--muted);
font-size: 0.85rem;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.6rem;
line-height: 1;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
.stern.an {
color: #f5c518;
}
.link {
padding: 0;
color: var(--muted);
font-size: 0.82rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.link:hover {
color: var(--text);
}
.stimmen {
display: flex;
flex-direction: column;
gap: 0.3rem;
list-style: none;
}
.stimmen li {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.punkte {
color: #f5c518;
letter-spacing: 0.05em;
}
.wer {
color: var(--muted);
font-size: 0.85rem;
}
.wer.eigen,
.eigen {
color: var(--accent);
font-weight: 600;
}
.weg {
padding: 0 0.3rem;
color: var(--muted);
font-size: 1rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
.host-zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding-top: 0.6rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--line);
}
.host-zeile label {
display: flex;
gap: 0.4rem;
align-items: center;
}
.host-zeile input[type='date'] {
padding: 0.25rem 0.45rem;
font-size: 0.8rem;
}
.klein {
padding: 0.3rem 0.65rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.klein.loeschen:hover {
color: var(--accent);
}
.fehler,
.leer {
color: var(--muted);
font-size: 0.88rem;
}
.fehler {
color: var(--accent);
}
/* ── Rechts ────────────────────────────────────────────────────────── */
.rechts {
flex: 0 0 50%;
min-width: 0;
padding: 1.25rem 1.25rem 2rem;
overflow-y: auto;
scrollbar-width: thin;
background: var(--bg-soft);
/* Der Falz: ein Schatten, der aus der Mitte kommt */
border-left: 1px solid var(--line);
box-shadow: inset 14px 0 18px -14px rgba(0, 0, 0, 0.9);
}
.schreiben {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 1.25rem;
}
/* Zurückhaltend — die Diskussion steht im Vordergrund, nicht der Knopf */
.schreib-knopf {
align-self: flex-start;
padding: 0.45rem 0.95rem;
color: var(--muted);
font-size: 0.84rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.schreib-knopf:hover {
color: var(--text);
border-color: var(--accent);
}
h3 {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 0.4rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 20px;
}
/* Zeilen mit Trennlinie, wie bei den Wünschen */
.liste {
list-style: none;
border-top: 1px solid var(--line);
}
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
}
.pille {
flex-shrink: 0;
min-width: 3rem;
margin-top: 0.1rem;
padding: 0.18rem 0.55rem;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 20px;
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.text {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
}
.zeile .meta {
display: block;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
/* ── Handy: beide Hälften untereinander ────────────────────────────── */
@media (max-width: 640px), (pointer: coarse) {
.stern {
padding: 0.1rem 0.2rem;
font-size: 1.8rem;
}
}
@media (max-width: 640px) {
.doppelseite {
flex-direction: column;
height: auto;
}
.links,
.rechts {
flex: none;
overflow: visible;
}
.links {
/* Posterformat, der Inhalt kann darüber hinauswachsen */
min-height: calc(100vw * 1.5);
}
.momentaufnahme {
padding: 80% 1rem 1.25rem;
}
.rechts {
padding: 1rem 1rem 1.5rem;
box-shadow: none;
border-top: 1px solid var(--line);
border-left: 0;
}
h2 {
font-size: 1.6rem;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup>
// Anmelden per Film: Wer einen Anmeldefilm hinterlegt hat, muss ihn erst
// in der Suche finden. Welcher es ist, verrät der Server nicht — hier
// steht nur, ob der Klick gesessen hat.
import { onMounted, onUnmounted, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
const props = defineProps({
name: { type: String, required: true },
// Gibt zurück, ob der geklickte Film der richtige war
pruefen: { type: Function, required: true },
})
const emit = defineEmits(['abbrechen'])
const fehler = ref('')
const laeuft = ref(false)
// Nach zwei Fehlversuchen steht da, dass es der Host auch richten kann
const versuche = ref(0)
async function probieren(film) {
if (laeuft.value) return
laeuft.value = true
fehler.value = ''
const ok = await props.pruefen(film.id)
laeuft.value = false
if (ok) return
versuche.value += 1
fehler.value = 'Das ist nicht der Film.'
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
}
onMounted(() => document.addEventListener('keydown', beiTaste))
onUnmounted(() => document.removeEventListener('keydown', beiTaste))
</script>
<template>
<Teleport to="body">
<div class="hintergrund" @click.self="emit('abbrechen')">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmelden als</p>
<h2>{{ name }}</h2>
<p class="frage">Welchen Film hast du hinterlegt?</p>
<FilmSuche platzhalter="Dein Anmeldefilm …" @waehlen="probieren" />
<p v-if="fehler" class="fehler">
{{ fehler }}
<span v-if="versuche >= 2" class="tipp">
Der Host kann dir in der Verwaltung einen neuen setzen.
</span>
</p>
<div class="fuss">
<button type="button" class="abbrechen" @click="emit('abbrechen')">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fehler {
color: var(--accent);
font-size: 0.88rem;
}
.tipp {
display: block;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.82rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,178 @@
<script setup>
// Suchfeld plus Posterraster — dieselbe Suche wie im Reiter, nur klein.
// Wird zweimal gebraucht: beim Anmelden und beim Hinterlegen des Films.
import { onMounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
defineProps({
platzhalter: { type: String, default: 'Film suchen …' },
})
const emit = defineEmits(['waehlen'])
const feld = ref(null)
const query = ref('')
const treffer = ref([])
const laden = ref(false)
let timer = null
let laufende = 0
async function suchen(begriff) {
const wort = begriff.trim()
if (wort.length < 2) {
treffer.value = []
return
}
const meine = ++laufende
laden.value = true
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(wort)}`)
const daten = resp.ok ? await resp.json() : []
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return
treffer.value = daten
} catch {
if (meine === laufende) treffer.value = []
} finally {
if (meine === laufende) laden.value = false
}
}
// Beim Tippen bremsen, bei Enter sofort
watch(query, (wort) => {
clearTimeout(timer)
timer = setTimeout(() => suchen(wort), 350)
})
function jetzt() {
clearTimeout(timer)
suchen(query.value)
}
onMounted(() => feld.value?.focus())
</script>
<template>
<div class="film-suche">
<input
ref="feld"
v-model="query"
type="search"
:placeholder="platzhalter"
@keyup.enter="jetzt"
/>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="query.trim().length < 2" class="note">
Tipp den Titel — dann klick den richtigen Film an.
</p>
<p v-else-if="!treffer.length" class="note">Nichts gefunden.</p>
<div v-else class="raster">
<button
v-for="film in treffer"
:key="film.id"
type="button"
class="kachel"
@click="emit('waehlen', film)"
>
<img
v-if="posterUrl(film.poster_path, 'w185')"
:src="posterUrl(film.poster_path, 'w185')"
:alt="film.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<span class="titel">{{ film.title }}</span>
<span v-if="film.year" class="jahr">{{ film.year }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.film-suche {
display: flex;
flex-direction: column;
gap: 0.8rem;
min-height: 0;
}
input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 0.95rem;
}
.note {
padding: 1.5rem 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
}
.raster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 0.6rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.kachel {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0;
text-align: left;
}
.kachel img,
.kein-cover {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
padding: 0.4rem;
color: var(--muted);
font-size: 0.7rem;
text-align: center;
}
.kachel:hover img,
.kachel:hover .kein-cover,
.kachel:focus-visible img {
border-color: var(--accent);
}
.titel {
overflow: hidden;
font-size: 0.78rem;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.jahr {
color: var(--muted);
font-size: 0.72rem;
}
@media (max-width: 640px), (pointer: coarse) {
input {
font-size: 16px;
}
.raster {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
// Ein Kommentar in der Diskussion: der Text mit Name, Datum und
// „Kommentieren“. Antworten rücken darunter ein — die Komponente ruft sich dafür selbst auf, so tief
// es geht. Ab der vierten Ebene nicht weiter, sonst bleibt auf dem Handy
// kein Platz für den Text.
import { ref } from 'vue'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
kommentar: { type: Object, required: true },
watchedId: { type: Number, required: true },
angemeldet: Boolean,
host: Boolean,
tiefe: { type: Number, default: 0 },
})
const antwortet = ref(false)
const entwurf = ref('')
async function antworten() {
const text = entwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.watchedId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, parent_id: props.kommentar.id }),
})
if (resp.ok) {
entwurf.value = ''
antwortet.value = false
}
}
async function loeschen() {
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
<template>
<li class="zeile">
<div class="inhalt">
<div class="markdown" v-html="rendern(kommentar.text)"></div>
<p class="meta">
<span :class="{ eigen: kommentar.mine }">{{ kommentar.name || 'Anonym' }}</span>
<span class="punkt">·</span>{{ datum(kommentar.created_at) }}
<template v-if="angemeldet">
<span class="punkt">·</span>
<button type="button" class="link" :class="{ offen: antwortet }" @click="antwortet = !antwortet">
Kommentieren
</button>
</template>
</p>
<MarkdownFeld
v-if="antwortet"
v-model="entwurf"
kompakt
class="neu"
:platzhalter="`Antwort an ${kommentar.name || 'Anonym'} …`"
knopf="Antworten"
@senden="antworten"
@abbrechen="antwortet = false"
/>
<ul v-if="kommentar.replies.length" class="antworten" :class="{ flach: tiefe >= 3 }">
<Kommentar
v-for="k in kommentar.replies"
:key="k.id"
:kommentar="k"
:watched-id="watchedId"
:angemeldet="angemeldet"
:host="host"
:tiefe="tiefe + 1"
/>
</ul>
</div>
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
<div class="aktionen">
<button
v-if="kommentar.mine || host"
type="button"
class="weg"
:title="kommentar.replies.length ? 'Samt allen Antworten darunter' : 'Kommentar entfernen'"
@click="loeschen"
>
×
</button>
</div>
</li>
</template>
<style scoped>
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
border-radius: 6px;
}
.zeile:hover {
background: rgba(255, 255, 255, 0.02);
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.meta {
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
.eigen {
color: var(--accent);
font-weight: 600;
}
.link {
padding: 0;
color: var(--muted);
font-size: inherit;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
}
.link:hover,
.link.offen {
color: var(--text);
text-decoration-color: currentColor;
}
.neu {
margin-top: 0.4rem;
}
/* Antworten hängen eingerückt unter dem Kommentar */
.antworten {
margin-top: 0.4rem;
padding-left: 0.4rem;
list-style: none;
border-left: 2px solid var(--line);
}
.antworten .zeile {
padding-right: 0;
border-bottom: 0;
}
/* Ab der vierten Ebene nicht weiter einrücken */
.antworten.flach {
padding-left: 0;
border-left: 0;
}
.aktionen {
display: flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.zeile:hover > .aktionen,
.zeile:focus-within > .aktionen {
opacity: 1;
}
.weg {
padding: 0 0.25rem;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.2;
}
.weg:hover {
color: var(--accent);
}
@media (hover: none) {
.aktionen {
opacity: 1;
}
}
@media (max-width: 640px), (pointer: coarse) {
.link,
.weg {
padding: 0.3rem 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,282 @@
<script setup>
// Ein Textfeld mit den paar Handgriffen, die man beim Schreiben braucht:
// fett, kursiv, Überschrift, Liste, Zitat — als Markdown, mit Vorschau.
// Klappt dort auf, wo man schreibt, statt über der Seite zu schweben.
// Dient für Rezensionen, Kommentare und Antworten; „kompakt“ macht es
// für Antworten eine Nummer kleiner.
import { computed, nextTick, onMounted, ref } from 'vue'
import { rendern } from '../markdown'
const props = defineProps({
platzhalter: { type: String, default: 'Schreib etwas …' },
knopf: { type: String, default: 'Senden' },
maxlaenge: { type: Number, default: 1000 },
kompakt: Boolean,
// Senden geht nur, wenn außerdem das hier stimmt — etwa Sterne gesetzt
bereit: { type: Boolean, default: true },
hinweis: { type: String, default: '' },
})
const emit = defineEmits(['senden', 'abbrechen'])
const text = defineModel({ type: String, default: '' })
const vorschau = ref(false)
const feld = ref(null)
const gerendert = computed(() => rendern(text.value))
const kannSenden = computed(() => props.bereit && text.value.trim().length > 0)
// ── Handgriffe: Markdown an der Cursorstelle einfügen ─────────────────
async function umschliessen(vor, nach = vor, platzhalter = 'Text') {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const gewaehlt = text.value.slice(a, b) || platzhalter
text.value = text.value.slice(0, a) + vor + gewaehlt + nach + text.value.slice(b)
await nextTick()
el.focus()
el.setSelectionRange(a + vor.length, a + vor.length + gewaehlt.length)
}
// Etwas an den Anfang jeder gewählten Zeile setzen — oder wieder weg
async function zeilenanfang(praefix) {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const t = text.value
const start = t.lastIndexOf('\n', a - 1) + 1
let ende = t.indexOf('\n', b)
if (ende === -1) ende = t.length
const neu = t
.slice(start, ende)
.split('\n')
.map((z) => (z.startsWith(praefix) ? z.slice(praefix.length) : praefix + z))
.join('\n')
text.value = t.slice(0, start) + neu + t.slice(ende)
await nextTick()
el.focus()
el.setSelectionRange(start, start + neu.length)
}
function senden() {
if (kannSenden.value) emit('senden')
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
else if (e.ctrlKey && e.key === 'Enter') senden()
else if (e.ctrlKey && e.key === 'b') {
e.preventDefault()
umschliessen('**')
} else if (e.ctrlKey && e.key === 'i') {
e.preventDefault()
umschliessen('*')
}
}
// Das Feld wächst mit dem Text. Hat man es selbst größer gezogen, bleibt
// das so — es wird nie kleiner als es gerade ist.
function wachsen() {
const el = feld.value
if (!el) return
const gewuenscht = Math.min(el.scrollHeight + 2, props.kompakt ? 320 : 640)
if (gewuenscht > el.offsetHeight) el.style.height = `${gewuenscht}px`
}
onMounted(async () => {
await nextTick()
wachsen()
feld.value?.focus()
})
</script>
<template>
<div class="markdown-feld" :class="{ kompakt }">
<div class="leiste">
<button type="button" title="Fett (Strg+B)" @click="umschliessen('**')"><b>F</b></button>
<button type="button" title="Kursiv (Strg+I)" @click="umschliessen('*')"><i>K</i></button>
<span class="trenner"></span>
<button v-if="!kompakt" type="button" title="Überschrift" @click="zeilenanfang('## ')">Ü</button>
<button type="button" title="Aufzählung" @click="zeilenanfang('- ')">•</button>
<button type="button" title="Nummerierung" @click="zeilenanfang('1. ')">1.</button>
<button type="button" title="Zitat" @click="zeilenanfang('> ')">„</button>
<span class="rest"></span>
<button type="button" class="umschalter" :class="{ an: vorschau }" @click="vorschau = !vorschau">
{{ vorschau ? 'Schreiben' : 'Vorschau' }}
</button>
</div>
<textarea
v-show="!vorschau"
ref="feld"
v-model="text"
:maxlength="maxlaenge"
:placeholder="platzhalter"
spellcheck="true"
@input="wachsen"
@keydown="beiTaste"
></textarea>
<div
v-show="vorschau"
class="vorschau markdown"
v-html="gerendert || '<p><em>Noch nichts geschrieben.</em></p>'"
></div>
<div class="fuss">
<span class="zaehler">{{ text.length }} / {{ maxlaenge }}</span>
<span v-if="hinweis && !bereit" class="hinweis">{{ hinweis }}</span>
<button type="button" class="abbrechen" @click="emit('abbrechen')">Abbrechen</button>
<button type="button" class="senden" :disabled="!kannSenden" @click="senden">
{{ knopf }}
</button>
</div>
</div>
</template>
<style scoped>
.markdown-feld {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
}
.leiste {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
.leiste button {
min-width: 2rem;
height: 2rem;
padding: 0 0.45rem;
color: var(--muted);
font-size: 0.92rem;
border-radius: 6px;
}
.leiste button:hover {
color: var(--text);
background: var(--bg-soft);
}
.trenner {
width: 1px;
height: 1.2rem;
margin: 0 0.25rem;
background: var(--line);
}
.rest {
flex: 1;
}
.umschalter {
font-size: 0.8rem;
border: 1px solid var(--line);
}
.umschalter.an {
color: var(--text);
border-color: var(--accent);
}
textarea,
.vorschau {
width: 100%;
min-height: 14rem;
padding: 0.7rem 0.85rem;
color: var(--text);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.55;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
/* Am Griff unten rechts zieht man es so groß, wie man will */
textarea {
overflow-y: auto;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
.vorschau {
max-height: 30rem;
overflow-y: auto;
}
.kompakt textarea,
.kompakt .vorschau {
min-height: 6rem;
font-size: 0.9rem;
}
.fuss {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.zaehler,
.hinweis {
color: var(--muted);
font-size: 0.76rem;
white-space: nowrap;
}
.zaehler {
flex: 1;
}
.hinweis {
color: var(--accent);
}
.abbrechen,
.senden {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
border-radius: 8px;
}
.abbrechen {
color: var(--muted);
border: 1px solid var(--line);
}
.abbrechen:hover {
color: var(--text);
}
.senden {
color: #fff;
background: var(--accent);
}
.senden:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 640px), (pointer: coarse) {
textarea {
font-size: 16px;
}
.leiste button {
min-width: 2.4rem;
height: 2.4rem;
}
}
</style>

View File

@@ -1,62 +1,59 @@
<script setup>
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { computed } from 'vue'
import MovieCard from './MovieCard.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
ich: { type: Object, default: null },
// Eine eigene Sammlung hat nur, wer in der Runde steht
angemeldet: Boolean,
ich: { type: Object, default: null },
gesehenIds: { type: Set, default: () => new Set() },
ansicht: { type: String, default: 'kompakt' },
// Leer heißt: alle Sammlungen zeigen
leute: { type: Array, default: () => [] },
// null = alles, true = nur Horror, false = alles außer Horror
horror: { default: null },
})
const emit = defineEmits(['open', 'changed'])
function passt(item) {
if (props.horror === null) return true
return (item.genres || []).includes('Horror') === props.horror
}
// Nach Person gruppiert, die eigene Sammlung zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const item of props.items) {
const schluessel = item.mine ? '\u0000eigene' : item.name || 'Anonym'
if (!nach.has(schluessel)) {
nach.set(schluessel, {
titel: item.mine
? `${props.ich?.name || 'Anonym'} (Deine)`
: item.name || 'Anonym',
if (!passt(item)) continue
const name = item.name || 'Anonym'
if (props.leute.length && !props.leute.includes(name)) continue
if (!nach.has(name)) {
nach.set(name, {
name,
titel: item.mine ? `${name} (Deine)` : name,
mine: item.mine,
filme: [],
})
}
nach.get(schluessel).filme.push(item)
nach.get(name).filme.push(item)
}
for (const gruppe of nach.values()) gruppe.schluessel = gruppe.titel
return [...nach.values()].sort((a, b) => b.mine - a.mine)
})
// Je Sammlung ein eigener Filter. Ohne Auswahl steht alles da.
const filter = ref({})
function umschalten(gruppe, wert) {
filter.value = {
...filter.value,
[gruppe.schluessel]: filter.value[gruppe.schluessel] === wert ? null : wert,
}
// Die Karte braucht die Kennung des Films, nicht die des Eintrags
function alsFilm(item) {
return { ...item, id: item.movie_id }
}
function gefiltert(gruppe) {
const wahl = filter.value[gruppe.schluessel]
if (!wahl) return gruppe.filme
return gruppe.filme.filter(
(f) => (f.genres || []).includes('Horror') === (wahl === 'horror')
)
}
const emit = defineEmits(['open', 'changed'])
async function vergessen(item) {
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
emit('changed')
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
async function oeffnen(film) {
const resp = await fetch(`/api/search/${film.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
@@ -66,126 +63,48 @@ async function oeffnen(item) {
<p v-if="!items.length" class="note">
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
</p>
<p v-else-if="!gruppen.length" class="note">
Nichts gefunden. Stell die Filter weiter.
</p>
<section v-for="gruppe in gruppen" :key="gruppe.titel">
<section v-for="gruppe in gruppen" :key="gruppe.name">
<h3>
{{ gruppe.titel }}
<span class="anzahl">{{ gefiltert(gruppe).length }}</span>
<span class="schalter">
<button
type="button"
:class="{ an: filter[gruppe.schluessel] === 'horror' }"
@click="umschalten(gruppe, 'horror')"
>
Horror
</button>
<button
type="button"
:class="{ an: filter[gruppe.schluessel] === 'kein' }"
@click="umschalten(gruppe, 'kein')"
>
Kein Horror
</button>
</span>
<span class="anzahl">{{ gruppe.filme.length }}</span>
</h3>
<div class="grid">
<article
v-for="item in gefiltert(gruppe)"
:key="item.id"
class="karte"
:class="{ gesehen: gesehenIds.has(item.movie_id) }"
>
<button type="button" class="cover" @click="oeffnen(item)">
<img
v-if="posterUrl(item.poster_path, 'w342')"
:src="posterUrl(item.poster_path, 'w342')"
:alt="item.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<div class="grid" :class="ansicht">
<!-- Der Entfernen-Knopf liegt über der Karte statt darin: eine
Karte ist selbst ein Knopf, und Knöpfe schachtelt man nicht -->
<div v-for="item in gruppe.filme" :key="item.id" class="halter">
<MovieCard
:movie="alsFilm(item)"
:ansicht="ansicht"
:gesehen="gesehenIds.has(item.movie_id)"
@open="oeffnen"
/>
<button
v-if="item.mine"
type="button"
class="weg"
title="Aus deiner Sammlung nehmen"
@click="vergessen(item)"
>
×
</button>
<div class="text">
<h5 @click="oeffnen(item)">{{ item.title }}</h5>
<p v-if="item.year" class="jahr">{{ item.year }}</p>
<p v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</p>
<button
v-if="item.mine"
type="button"
class="weg"
@click="vergessen(item)"
>
Entfernen
</button>
</div>
</article>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Schon gelaufen — die Karte tritt zurück, kommt beim Überfahren wieder */
.karte.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.karte.gesehen:hover {
opacity: 1;
filter: none;
}
.karte {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.cover {
flex-shrink: 0;
width: 82px;
padding: 0;
}
.cover img,
.kein-cover {
width: 82px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.7rem;
background: var(--bg);
}
.text {
display: flex;
flex-direction: column;
gap: 0.3rem;
.merkliste-tab {
min-width: 0;
}
section {
margin-bottom: 1.75rem;
margin-bottom: 2rem;
}
h3 {
@@ -208,62 +127,58 @@ h3 {
border-radius: 20px;
}
.schalter {
display: flex;
margin-left: 0.4rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.schalter button {
padding: 0.2rem 0.6rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--bg);
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
oder die halbe Reihe, falls die schmaler ist. */
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.schalter button.an {
color: var(--text);
background: var(--bg-soft);
}
.genres {
color: var(--muted);
font-size: 0.78rem;
}
h5 {
font-size: 0.98rem;
line-height: 1.25;
cursor: pointer;
}
h5:hover {
color: var(--accent);
}
.jahr {
color: var(--muted);
font-size: 0.8rem;
.halter {
position: relative;
min-width: 0;
}
.weg {
align-self: flex-start;
margin-top: 0.35rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.75rem;
border: 1px solid var(--line);
border-radius: 6px;
position: absolute;
top: 0.4rem;
left: 0.4rem;
z-index: 2;
display: grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
color: #fff;
font-size: 1.2rem;
line-height: 1;
opacity: 0;
transition: opacity 0.12s;
background: rgba(0, 0, 0, 0.75);
border-radius: 50%;
}
.halter:hover .weg,
.weg:focus-visible {
opacity: 1;
}
.weg:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Ohne Maus gibt es kein Überfahren — dann steht der Knopf einfach da */
@media (hover: none) {
.weg {
opacity: 1;
}
}
.note {
@@ -271,4 +186,15 @@ h5:hover {
color: var(--muted);
text-align: center;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,21 +1,39 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import FilmLogin from './FilmLogin.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
mine: { type: Object, default: null },
// Der Host wechselt frei, ohne den Anmeldefilm zu suchen
host: Boolean,
// (user_id, movie_id) => Promise<ok>. Bei falschem Film kommt false.
anmelden: { type: Function, required: true },
})
const emit = defineEmits(['waehlen'])
// Firefox zeichnet die Optionsliste eines <select> unter Linux im
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
const offen = ref(false)
const halter = ref(null)
function waehlen(id) {
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
const anmeldung = ref(null)
function waehlen(benutzer) {
offen.value = false
emit('waehlen', id)
if (!benutzer) return props.anmelden(null)
// Beim eigenen Namen fragt der Server nicht noch einmal nach
if (benutzer.geschuetzt && !props.host && props.mine?.id !== benutzer.id) {
anmeldung.value = benutzer
return
}
props.anmelden(benutzer.id)
}
async function pruefen(movieId) {
const ok = await props.anmelden(anmeldung.value.id, movieId)
if (ok) anmeldung.value = null
return ok
}
function daneben(e) {
@@ -50,11 +68,19 @@ onUnmounted(() => {
</button>
</li>
<li v-for="u in items" :key="u.id">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u.id)">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
{{ u.name }}
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">&#9679;</span>
</button>
</li>
</ul>
<FilmLogin
v-if="anmeldung"
:name="anmeldung.name"
:pruefen="pruefen"
@abbrechen="anmeldung = null"
/>
</div>
</template>
@@ -114,6 +140,14 @@ onUnmounted(() => {
font-weight: 600;
}
/* Der Punkt zeigt: hier fragt die Seite nach dem Anmeldefilm */
.schloss {
margin-left: 0.3rem;
color: var(--muted);
font-size: 0.6rem;
vertical-align: middle;
}
@media (max-width: 640px), (pointer: coarse) {
.knopf {
padding: 0.45rem 0.85rem;

View File

@@ -0,0 +1,254 @@
<script setup>
// Die Leiste rechts im Sammlungen-Reiter. Gleiche Bauart wie die
// Filterleiste im Katalog, nur mit dem, was hier zählt: wessen Sammlung,
// Horror oder nicht, und wie groß die Karten sind.
import { computed } from 'vue'
const props = defineProps({
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
leute: { type: Array, default: () => [] },
})
const gewaehlt = defineModel('gewaehlt', { type: Array, default: () => [] })
// null = alles, true = nur Horror, false = alles außer Horror
const horror = defineModel('horror', { default: null })
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
const alleGewaehlt = computed(() => gewaehlt.value.length === 0)
function umschalten(name) {
gewaehlt.value = gewaehlt.value.includes(name)
? gewaehlt.value.filter((n) => n !== name)
: [...gewaehlt.value, name]
}
// Ein zweiter Klick auf denselben Knopf hebt die Auswahl wieder auf
function horrorSetzen(wert) {
horror.value = horror.value === wert ? null : wert
}
const etwasAktiv = computed(() => !alleGewaehlt.value || horror.value !== null)
function zuruecksetzen() {
gewaehlt.value = []
horror.value = null
}
</script>
<template>
<aside class="sidebar">
<div class="controls">
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
Details
</button>
</div>
</div>
<div class="head">
<h3>Filter</h3>
<button v-if="etwasAktiv" type="button" class="reset" @click="zuruecksetzen">
zurücksetzen
</button>
</div>
<section>
<h4>Genre</h4>
<div class="badges">
<button
type="button"
class="badge"
:class="{ on: horror === true }"
@click="horrorSetzen(true)"
>
Horror
</button>
<button
type="button"
class="badge"
:class="{ on: horror === false }"
@click="horrorSetzen(false)"
>
Kein Horror
</button>
</div>
</section>
<section>
<h4>Sammlungen</h4>
<div class="badges">
<button
type="button"
class="badge alle"
:class="{ on: alleGewaehlt }"
@click="gewaehlt = []"
>
alle
</button>
<button
v-for="person in leute"
:key="person.name"
type="button"
class="badge"
:class="{ on: gewaehlt.includes(person.name) }"
@click="umschalten(person.name)"
>
{{ person.name }}
<span class="count">{{ person.anzahl }}</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);
}
.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);
}
.ansicht {
display: flex;
gap: 0.3rem;
}
.ansicht button {
flex: 1;
padding: 0.4rem 0.5rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
}
.ansicht button:hover {
color: var(--text);
}
.ansicht button.an {
color: var(--text);
font-weight: 600;
border-color: var(--accent);
}
/* Finger statt Maus: alles etwas größer */
@media (max-width: 640px), (pointer: coarse) {
.sidebar {
gap: 1.4rem;
padding: 1rem;
}
.ansicht button {
padding: 0.65rem 0.5rem;
font-size: 0.9rem;
}
.badge {
padding: 0.5rem 0.85rem;
font-size: 0.9rem;
}
.badges {
gap: 0.45rem;
}
.reset {
padding: 0.4rem 0;
font-size: 0.85rem;
}
}
</style>

View File

@@ -1,13 +1,51 @@
<script setup>
import { ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
import { posterUrl } from '../images'
defineProps({
const props = defineProps({
items: { type: Array, default: () => [] },
// Der Host pflegt die ganze Runde, alle anderen nur den eigenen Zugang
host: Boolean,
ich: { type: Object, default: null },
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id)
)
const neuerName = ref('')
const fehler = ref('')
// Der Anmeldefilm ist das Passwort: wer den Namen wählt, muss ihn in der
// Suche finden. Welcher es ist, gibt der Server nicht heraus — hier steht
// nur, ob überhaupt einer gesetzt ist.
const setzt = ref(null)
async function schutzSetzen(user, movieId) {
await fetch(`/api/users/${user.id}/schutz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
delete gezeigt[user.id]
setzt.value = null
}
// Wer den Film ändern darf, darf ihn auch nachschlagen — sonst weiß man
// nicht, was man gerade überschreibt. Sichtbar wird er erst auf Klick.
const gezeigt = reactive({})
async function schutzZeigen(user) {
if (gezeigt[user.id]) {
delete gezeigt[user.id]
return
}
gezeigt[user.id] = { laden: true }
const resp = await fetch(`/api/users/${user.id}/schutz`)
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
@@ -27,9 +65,14 @@ async function entfernen(user) {
<template>
<div class="verwaltung-tab">
<section>
<h3>Benutzer</h3>
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<div class="anlegen">
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen
Namen danach. Ohne Film bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
<input
v-model="neuerName"
type="text"
@@ -41,27 +84,101 @@ async function entfernen(user) {
</div>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<ul v-if="items.length" class="liste">
<li v-for="u in items" :key="u.id">
<span class="name">{{ u.name }}</span>
<button
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
<ul v-if="liste.length" class="liste">
<li v-for="u in liste" :key="u.id">
<div class="zeile">
<span class="name">{{ u.name }}</span>
<button
v-if="u.geschuetzt"
type="button"
class="auge"
:class="{ an: gezeigt[u.id] }"
:title="gezeigt[u.id] ? 'Film verbergen' : 'Hinterlegten Film zeigen'"
@click="schutzZeigen(u)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
<circle cx="12" cy="12" r="3.2" />
<line v-if="!gezeigt[u.id]" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button type="button" class="klein" @click="setzt = u">
{{ u.geschuetzt ? 'Ändern' : 'Anmeldefilm' }}
</button>
<button
v-if="u.geschuetzt"
type="button"
class="klein"
title="Namen wieder für alle öffnen"
@click="schutzSetzen(u, null)"
>
Aufheben
</button>
<button
v-if="host"
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
</div>
<p v-if="gezeigt[u.id]" class="verraten">
<template v-if="gezeigt[u.id].laden">Moment…</template>
<template v-else-if="gezeigt[u.id].film">
<img
v-if="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:src="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:alt="gezeigt[u.id].film.title"
/>
<span>
{{ gezeigt[u.id].film.title }}
<span v-if="gezeigt[u.id].film.year" class="jahr">
{{ gezeigt[u.id].film.year }}
</span>
</span>
</template>
<template v-else>Der Film ließ sich nicht laden.</template>
</p>
</li>
</ul>
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmeldefilm für</p>
<h2>{{ setzt.name }}</h2>
<p class="frage">
{{
setzt.id === ich?.id
? 'Diesen Film musst du beim Wechsel auf deinen Namen anklicken.'
: `Diesen Film muss ${setzt.name} beim Wechsel anklicken.`
}}
</p>
<FilmSuche
platzhalter="Film als Passwort …"
@waehlen="schutzSetzen(setzt, $event.id)"
/>
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.verwaltung-tab {
max-width: 26rem;
max-width: 34rem;
}
h3 {
@@ -107,14 +224,66 @@ h3 {
.liste li {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 0.55rem 0.85rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.zeile {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Der hinterlegte Film, aufgeklappt */
.verraten {
display: flex;
gap: 0.6rem;
align-items: center;
padding-top: 0.5rem;
color: var(--text);
font-size: 0.9rem;
border-top: 1px solid var(--line);
}
.verraten img {
width: 40px;
border-radius: 4px;
}
.jahr {
color: var(--muted);
font-size: 0.82rem;
}
.auge {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 6px;
}
.auge svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.auge:hover,
.auge.an {
color: var(--text);
border-color: var(--accent);
}
.name {
flex: 1;
font-size: 0.95rem;
@@ -132,13 +301,117 @@ h3 {
}
.leer,
.fehler {
.fehler,
.erklaerung {
color: var(--muted);
font-size: 0.88rem;
}
.erklaerung {
margin-bottom: 1rem;
line-height: 1.5;
}
.fehler {
margin-bottom: 0.8rem;
color: var(--accent);
}
.marke {
padding: 0.1rem 0.4rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: 4px;
}
.klein {
padding: 0.25rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Fenster zum Setzen ──────────────────────────────────────────── */
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.fenster h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,32 @@
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht,
// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage,
// alles je Browser.
import { onMounted, onUnmounted, ref, watch } from 'vue'
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht und
// der Schalter für gesehene Filme — alles im localStorage, alles je
// Browser. Der Hostmodus nicht: der hängt an der Sitzung auf dem Server.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt.
async function hostSetzen(an, schluessel = null) {
try {
const resp = await fetch('/api/host', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an, schluessel }),
})
if (!resp.ok) return false
return (await resp.json()).host
} catch {
return false
}
}
async function hostStand() {
try {
const resp = await fetch('/api/host')
return resp.ok ? (await resp.json()).host : false
} catch {
return false
}
}
const TAB_IDS = [
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
@@ -30,24 +55,45 @@ export function useEinstellungen() {
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
// Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
const host = ref(localStorage.getItem('filmabend_host') === '1')
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
// der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet
// die Tastenkombination um, damit der Host die Gastansicht sehen kann.
const freigeschaltet = ref(false)
const anzeige = ref(localStorage.getItem('filmabend_host') !== '0')
const host = computed(() => freigeschaltet.value && anzeige.value)
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
watch(host, (an) => {
const weg = an ? 'merkliste' : 'verwaltung'
if (activeTab.value === weg) activeTab.value = 'netflix'
// Der Host-Link: /?host=<schlüssel>. Nach dem Einlösen fliegt er aus der
// Adresszeile, damit er nicht im Verlauf oder in einem Screenshot landet.
async function linkEinloesen() {
const adresse = new URL(window.location.href)
const schluessel = adresse.searchParams.get('host')
if (!schluessel) return false
const an = await hostSetzen(true, schluessel)
adresse.searchParams.delete('host')
window.history.replaceState({}, '', adresse)
return an
}
onMounted(async () => {
// Der Link kommt zuerst: er schaltet frei und zeigt die Knöpfe gleich
if (await linkEinloesen()) {
freigeschaltet.value = true
anzeige.value = true
return
}
freigeschaltet.value = await hostStand()
})
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem('filmabend_host', host.value ? '1' : '0')
// Ohne freigeschaltete Sitzung gibt es nichts umzuschalten
if (freigeschaltet.value) anzeige.value = !anzeige.value
}
}

View File

@@ -26,12 +26,15 @@ export function useMeins({ host }) {
ich.value = daten.mine
}
async function michSetzen(id) {
await fetch('/api/users/waehlen', {
// Gibt zurück, ob der Wechsel durchging — bei hinterlegtem Anmeldefilm
// lehnt der Server ohne die richtige movie_id ab
async function michSetzen(id, movieId = null) {
const resp = await fetch('/api/users/waehlen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
body: JSON.stringify({ user_id: id ?? null, movie_id: movieId }),
})
if (!resp.ok) return false
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
await ladeRunde()
@@ -39,6 +42,7 @@ export function useMeins({ host }) {
ladeMerkliste()
ladeGesehen()
ladeWuensche()
return true
}
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,

78
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,78 @@
// Ein kleiner Markdown-Renderer für Rezensionen. Kann, was man beim
// Schreiben über einen Film braucht: Absätze, fett, kursiv, Überschriften,
// Listen, Zitate. Mehr nicht — und keine Abhängigkeit dafür.
//
// Der Text wird zuerst entschärft, dann erst kommen die Tags dazu. So
// landet nie fremdes HTML in der Seite.
function entschaerfen(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
return entschaerfen(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+?)\*/g, '$1<em>$2</em>')
.replace(/`([^`\n]+?)`/g, '<code>$1</code>')
}
export function rendern(text) {
const out = []
let absatz = []
let liste = null
let zitat = []
const absatzZu = () => {
if (absatz.length) out.push(`<p>${absatz.map(inline).join('<br>')}</p>`)
absatz = []
}
const listeZu = () => {
if (liste) out.push(`</${liste}>`)
liste = null
}
const zitatZu = () => {
if (zitat.length) out.push(`<blockquote>${zitat.map(inline).join('<br>')}</blockquote>`)
zitat = []
}
const allesZu = () => {
absatzZu()
listeZu()
zitatZu()
}
for (const zeile of (text || '').split('\n')) {
const ueberschrift = zeile.match(/^(#{1,3})\s+(.*)$/)
const punkt = zeile.match(/^\s*[-*•]\s+(.*)$/)
const nummer = zeile.match(/^\s*\d+[.)]\s+(.*)$/)
const zit = zeile.match(/^>\s?(.*)$/)
if (ueberschrift) {
allesZu()
// Eine Stufe kleiner als die Seitentitel
const stufe = ueberschrift[1].length + 3
out.push(`<h${stufe}>${inline(ueberschrift[2])}</h${stufe}>`)
} else if (punkt || nummer) {
absatzZu()
zitatZu()
const art = punkt ? 'ul' : 'ol'
if (liste !== art) {
listeZu()
out.push(`<${art}>`)
liste = art
}
out.push(`<li>${inline((punkt || nummer)[1])}</li>`)
} else if (zit) {
absatzZu()
listeZu()
zitat.push(zit[1])
} else if (!zeile.trim()) {
allesZu()
} else {
listeZu()
zitatZu()
absatz.push(zeile)
}
}
allesZu()
return out.join('')
}

View File

@@ -53,6 +53,55 @@ a {
color: inherit;
}
/* Gerenderter Text aus Rezensionen — Editor-Vorschau und Seite gleich */
.markdown {
font-size: 0.95rem;
line-height: 1.55;
}
.markdown > * + * {
margin-top: 0.6em;
}
.markdown h4,
.markdown h5,
.markdown h6 {
font-size: 1.05rem;
line-height: 1.3;
}
.markdown h5 {
font-size: 1rem;
}
.markdown h6 {
font-size: 0.95rem;
color: var(--muted);
}
.markdown ul,
.markdown ol {
padding-left: 1.3em;
}
.markdown li + li {
margin-top: 0.2em;
}
.markdown blockquote {
padding-left: 0.8em;
color: var(--muted);
border-left: 3px solid var(--line);
}
.markdown code {
padding: 0.05em 0.35em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
}
/* Unter 16 px zoomt iOS beim Tippen in jedes Feld */
@media (pointer: coarse) {
input,