update
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
TMDB_TOKEN=
|
TMDB_TOKEN=
|
||||||
MINIMAX_KEY=
|
MINIMAX_KEY=
|
||||||
MTX_SENDEPASSWORT=
|
MTX_SENDEPASSWORT=
|
||||||
|
HOST_SCHLUESSEL=
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -18,6 +18,37 @@ cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen
|
|||||||
make install
|
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
|
## Sync
|
||||||
|
|
||||||
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige
|
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ load_dotenv(ROOT / ".env")
|
|||||||
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
|
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
|
||||||
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
|
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"
|
TMDB_BASE = "https://api.themoviedb.org/3"
|
||||||
IMAGE_BASE = "https://image.tmdb.org/t/p"
|
IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,25 @@ def existing_netflix_ids(conn):
|
|||||||
return {row["id"]: row["netflix_id"] for row in rows}
|
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):
|
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()}
|
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:
|
if gone:
|
||||||
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
|
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
|
||||||
return len(gone)
|
return len(gone)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
@@ -22,7 +23,7 @@ from . import (
|
|||||||
watched,
|
watched,
|
||||||
wishlist,
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
|
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
|
||||||
@@ -93,12 +94,19 @@ async def startup():
|
|||||||
features.init()
|
features.init()
|
||||||
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
|
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
|
||||||
# ein sauberer Stand
|
# 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(sicherung, "cron", hour=4, minute=0, id="daily-backup")
|
||||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
if not db.get_meta("last_sync"):
|
if not db.get_meta("last_sync"):
|
||||||
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
||||||
asyncio.create_task(guarded_sync())
|
asyncio.create_task(guarded_sync())
|
||||||
|
else:
|
||||||
|
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
|
||||||
|
asyncio.create_task(sync.katalog_nachtragen())
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@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):
|
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)
|
movie = db.get_movie(movie_id)
|
||||||
if movie:
|
if movie:
|
||||||
return movie
|
return movie
|
||||||
try:
|
try:
|
||||||
return await search.detail(movie_id)
|
movie = await search.detail(movie_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.upsert(conn, movie)
|
||||||
|
return movie
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/features")
|
@app.get("/api/features")
|
||||||
@@ -493,7 +508,11 @@ def vergessen(
|
|||||||
@app.get("/api/users")
|
@app.get("/api/users")
|
||||||
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
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")
|
@app.post("/api/users")
|
||||||
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
|
|||||||
return {"removed": 1}
|
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")
|
@app.post("/api/users/waehlen")
|
||||||
def name_waehlen(
|
def name_waehlen(
|
||||||
response: Response,
|
response: Response,
|
||||||
user_id: int | None = Body(None, embed=True),
|
user_id: int | None = Body(None, embed=True),
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
filmabend_sid: str | None = Cookie(None),
|
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)
|
sid = sitzung(response, filmabend_sid)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
users.abwaehlen(sid)
|
users.abwaehlen(sid)
|
||||||
return {"mine": None}
|
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)
|
benutzer = users.waehlen(sid, user_id)
|
||||||
if not benutzer:
|
if not benutzer:
|
||||||
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
||||||
@@ -576,14 +694,13 @@ def bewerten(
|
|||||||
watched_id: int,
|
watched_id: int,
|
||||||
response: Response,
|
response: Response,
|
||||||
stars: int = Body(..., embed=True),
|
stars: int = Body(..., embed=True),
|
||||||
text: str | None = Body(None, embed=True),
|
|
||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
mitglied(sid)
|
mitglied(sid)
|
||||||
stars = max(1, min(watched.MAX_STERNE, stars))
|
stars = max(1, min(watched.MAX_STERNE, stars))
|
||||||
# Der Name kommt aus der Runde, nicht aus der Anfrage
|
# 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:
|
if not eintrag:
|
||||||
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
||||||
events.senden("watched")
|
events.senden("watched")
|
||||||
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
|
|||||||
return {"removed": 1}
|
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")
|
@app.delete("/api/suggestions/alle")
|
||||||
def runde_zuruecksetzen():
|
def runde_zuruecksetzen():
|
||||||
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""
|
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import quote_plus
|
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")
|
log = logging.getLogger("sync")
|
||||||
|
|
||||||
@@ -144,6 +144,40 @@ async def resolve_canon():
|
|||||||
return places
|
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():
|
async def run():
|
||||||
log.info("Sync startet")
|
log.info("Sync startet")
|
||||||
canon_places = await resolve_canon()
|
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, "movie_count", len(on_netflix))
|
||||||
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
||||||
|
|
||||||
|
nachgetragen = await katalog_nachtragen()
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
|
"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(movies),
|
||||||
len(on_netflix),
|
len(on_netflix),
|
||||||
sum(1 for m in movies if m["canon_group"] is not None),
|
sum(1 for m in movies if m["canon_group"] is not None),
|
||||||
linked,
|
linked,
|
||||||
new_teasers,
|
new_teasers,
|
||||||
removed,
|
removed,
|
||||||
|
nachgetragen,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"movies": len(movies),
|
"movies": len(movies),
|
||||||
@@ -196,6 +233,7 @@ async def run():
|
|||||||
"netflix_links": linked,
|
"netflix_links": linked,
|
||||||
"new_teasers": new_teasers,
|
"new_teasers": new_teasers,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
|
"nachgetragen": nachgetragen,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Die Runde: wer mitmacht, und wer gerade wer ist.
|
"""Die Runde: wer mitmacht, und wer gerade wer ist.
|
||||||
|
|
||||||
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
|
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
|
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
|
||||||
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
|
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,
|
user_id INTEGER NOT NULL,
|
||||||
seit TEXT 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():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA)
|
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():
|
def alle():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
|
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):
|
def anlegen(name):
|
||||||
@@ -59,7 +82,24 @@ def anlegen(name):
|
|||||||
def von_name(name):
|
def von_name(name):
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
|
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):
|
def entfernen(user_id):
|
||||||
@@ -84,7 +124,7 @@ def waehlen(session_id, user_id):
|
|||||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return dict(da)
|
return _oeffentlich(da)
|
||||||
|
|
||||||
|
|
||||||
def abwaehlen(session_id):
|
def abwaehlen(session_id):
|
||||||
@@ -103,7 +143,32 @@ def aktuell(session_id):
|
|||||||
"WHERE s.session_id = ?",
|
"WHERE s.session_id = ?",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchone()
|
).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):
|
def besitzer(session_id):
|
||||||
|
|||||||
@@ -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
|
Der Host trägt ein, was am Abend lief. Jeder Film bekommt eine Seite mit
|
||||||
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
|
zwei Teilen. Links die Momentaufnahme: eine Wertung je Person, nur Sterne,
|
||||||
freiwillig, ohne ihn zählt die Stimme trotzdem.
|
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,
|
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 datetime import datetime, timezone
|
||||||
|
|
||||||
from . import users
|
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
|
MAX_STERNE = 5
|
||||||
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
|
# 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():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA + RATINGS)
|
conn.executescript(SCHEMA + RATINGS + NOTES)
|
||||||
# Eine Stimme je Person und Film
|
# Eine Stimme je Person und Film
|
||||||
users.je_person(conn, "ratings", RATINGS, "watched_id")
|
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
|
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
|
||||||
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
|
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
|
||||||
if "hidden" not in spalten:
|
if "hidden" not in spalten:
|
||||||
@@ -66,43 +107,80 @@ def _jetzt():
|
|||||||
|
|
||||||
|
|
||||||
def alle(session_id):
|
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:
|
with connect() as conn:
|
||||||
filme = conn.execute(
|
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()
|
).fetchall()
|
||||||
stimmen = conn.execute(
|
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"
|
"FROM ratings ORDER BY created_at ASC"
|
||||||
).fetchall()
|
).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)
|
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 = {}
|
je_film = {}
|
||||||
for s in stimmen:
|
for s in stimmen:
|
||||||
je_film.setdefault(s["watched_id"], []).append(s)
|
je_film.setdefault(s["watched_id"], []).append(s)
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for film in filme:
|
for nummer, film in zip(range(len(filme), 0, -1), filme):
|
||||||
eigene = je_film.get(film["id"], [])
|
eigene = je_film.get(film["id"], [])
|
||||||
werte = [s["stars"] for s in eigene]
|
werte = [s["stars"] for s in eigene]
|
||||||
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
|
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(
|
out.append(
|
||||||
{
|
{
|
||||||
**dict(film),
|
**daten,
|
||||||
|
"nummer": nummer,
|
||||||
"ratings": [
|
"ratings": [
|
||||||
{
|
{
|
||||||
"id": s["id"],
|
"id": s["id"],
|
||||||
"stars": s["stars"],
|
"stars": s["stars"],
|
||||||
"name": s["name"],
|
"name": s["name"],
|
||||||
"text": s["text"],
|
"created_at": s["created_at"],
|
||||||
"mine": gehoert_mir(s, session_id, ich),
|
"mine": gehoert_mir(s, session_id, ich),
|
||||||
}
|
}
|
||||||
for s in eigene
|
for s in eigene
|
||||||
],
|
],
|
||||||
|
"notes": kommentare.get(film["id"], []),
|
||||||
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
||||||
"count": len(werte),
|
"count": len(werte),
|
||||||
"mine": meine["stars"] if meine else None,
|
"mine": meine["stars"] if meine else None,
|
||||||
"mine_text": meine["text"] if meine else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -130,7 +208,9 @@ def markieren(movie):
|
|||||||
|
|
||||||
|
|
||||||
def entfernen(watched_id):
|
def entfernen(watched_id):
|
||||||
|
"""Samt Wertungen und Kommentaren."""
|
||||||
with connect() as conn:
|
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,))
|
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"DELETE FROM watched WHERE id = ?", (watched_id,)
|
"DELETE FROM watched WHERE id = ?", (watched_id,)
|
||||||
@@ -156,15 +236,9 @@ def sichtbarkeit(watched_id, versteckt):
|
|||||||
).rowcount
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
def bewerten(watched_id, session_id, stars, name=None, text=None):
|
def bewerten(watched_id, session_id, stars, name=None):
|
||||||
"""Sterne und ein optionaler Satz dazu.
|
"""Sterne setzen. Gibt None zurück, wenn der Film nicht in der Liste
|
||||||
|
steht."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
da = conn.execute(
|
da = conn.execute(
|
||||||
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
|
"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:
|
if not da:
|
||||||
return None
|
return None
|
||||||
ich = besitzer(session_id)
|
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
|
# Eine Stimme je Person — die eigene alte fliegt vorher raus
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
|
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(
|
conn.execute(
|
||||||
"INSERT INTO ratings "
|
"INSERT INTO ratings "
|
||||||
"(watched_id, session_id, user_id, stars, name, text, created_at) "
|
"(watched_id, session_id, user_id, stars, name, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
|
||||||
watched_id,
|
|
||||||
session_id,
|
|
||||||
ich,
|
|
||||||
stars,
|
|
||||||
(name or "").strip()[:40] or None,
|
|
||||||
text,
|
|
||||||
_jetzt(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return {"watched_id": watched_id, "stars": stars, "text": text}
|
return {"watched_id": watched_id, "stars": stars}
|
||||||
|
|
||||||
|
|
||||||
def stimme_loeschen(rating_id):
|
def stimme_loeschen(rating_id):
|
||||||
@@ -210,6 +268,70 @@ def stimme_loeschen(rating_id):
|
|||||||
).rowcount
|
).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():
|
def ids():
|
||||||
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
|||||||
@@ -37,13 +37,20 @@ def init():
|
|||||||
users.je_person(conn, "wishlist", SCHEMA, "movie_id")
|
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):
|
def alle(session_id):
|
||||||
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
|
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
|
||||||
|
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
|
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
|
||||||
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
|
# 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 users u ON u.id = w.user_id "
|
||||||
"LEFT JOIN movies m ON m.id = w.movie_id "
|
"LEFT JOIN movies m ON m.id = w.movie_id "
|
||||||
"ORDER BY w.created_at DESC"
|
"ORDER BY w.created_at DESC"
|
||||||
@@ -54,9 +61,10 @@ def alle(session_id):
|
|||||||
**{
|
**{
|
||||||
k: v
|
k: v
|
||||||
for k, v in dict(r).items()
|
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 [],
|
"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),
|
"mine": gehoert_mir(r, session_id, ich),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|||||||
Binary file not shown.
@@ -12,6 +12,8 @@ _ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
|
|||||||
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
||||||
os.environ.setdefault("TMDB_TOKEN", "test")
|
os.environ.setdefault("TMDB_TOKEN", "test")
|
||||||
os.environ.setdefault("MINIMAX_KEY", "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
|
import pytest # noqa: E402
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
@@ -85,6 +87,21 @@ def zuschauer():
|
|||||||
return TestClient(main.app)
|
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
|
@pytest.fixture
|
||||||
def film():
|
def film():
|
||||||
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""
|
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""
|
||||||
|
|||||||
156
backend/tests/test_gaestebuch.py
Normal file
156
backend/tests/test_gaestebuch.py
Normal 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
185
backend/tests/test_login.py
Normal 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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Wertungen: Sterne, Text, Schnitt."""
|
"""Wertungen: Sterne, Text, Schnitt."""
|
||||||
|
|
||||||
from app.watched import MAX_TEXT
|
|
||||||
|
|
||||||
|
|
||||||
def gesehen(client, film, movie_id=1):
|
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"]
|
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_wertung_mit_text(client, film):
|
def test_wertung_ist_nur_sterne(client, film):
|
||||||
wid = gesehen(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})
|
||||||
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
|
assert antwort.json() == {"watched_id": wid, "stars": 4}
|
||||||
assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"}
|
eintrag = client.get("/api/watched").json()[0]
|
||||||
|
assert eintrag["mine"] == 4
|
||||||
[eintrag] = client.get("/api/watched").json()
|
|
||||||
assert eintrag["average"] == 4.0
|
assert eintrag["average"] == 4.0
|
||||||
assert eintrag["count"] == 1
|
assert eintrag["count"] == 1
|
||||||
assert eintrag["mine"] == 4
|
assert "text" not in eintrag["ratings"][0]
|
||||||
assert eintrag["mine_text"] == "Gruselig"
|
assert "mine_text" not in eintrag
|
||||||
assert eintrag["ratings"][0]["mine"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_none_behaelt_leer_loescht(client, film):
|
def test_neue_sterne_ersetzen_die_alten(client, film):
|
||||||
wid = gesehen(client, film)
|
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
|
||||||
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 2})
|
||||||
# Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme
|
eintrag = client.get("/api/watched").json()[0]
|
||||||
client.post(f"/api/watched/{wid}/rating", json={"stars": 3})
|
assert eintrag["mine"] == 2
|
||||||
[eintrag] = client.get("/api/watched").json()
|
|
||||||
assert eintrag["mine"] == 3
|
|
||||||
assert eintrag["mine_text"] == "Gruselig"
|
|
||||||
assert eintrag["count"] == 1
|
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 = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
wid = gesehen(client, film)
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 5})
|
||||||
client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)})
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE ratings SET text = 'Alte Rezension'")
|
||||||
|
watched.init()
|
||||||
|
|
||||||
[eintrag] = client.get("/api/watched").json()
|
f = client.get("/api/watched").json()[0]
|
||||||
assert len(eintrag["mine_text"]) == MAX_TEXT
|
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):
|
def test_fremde_wertung_ist_nicht_meine(client, client2, film):
|
||||||
|
|||||||
110
backend/tests/test_wishlist.py
Normal file
110
backend/tests/test_wishlist.py
Normal 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
|
||||||
@@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue'
|
|||||||
import FilterSidebar from './components/FilterSidebar.vue'
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
import MerklisteTab from './components/MerklisteTab.vue'
|
import MerklisteTab from './components/MerklisteTab.vue'
|
||||||
|
import SammlungFilter from './components/SammlungFilter.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
import NamensWahl from './components/NamensWahl.vue'
|
import NamensWahl from './components/NamensWahl.vue'
|
||||||
import VerwaltungTab from './components/VerwaltungTab.vue'
|
import VerwaltungTab from './components/VerwaltungTab.vue'
|
||||||
@@ -29,6 +30,11 @@ const {
|
|||||||
wuensche, ladeWuensche,
|
wuensche, ladeWuensche,
|
||||||
} = useMeins({ host })
|
} = 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 {
|
const {
|
||||||
movies, loading, error, load,
|
movies, loading, error, load,
|
||||||
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
||||||
@@ -84,16 +90,42 @@ const tabs = computed(() => [
|
|||||||
{ id: 'stream', label: 'Stream', count: null },
|
{ id: 'stream', label: 'Stream', count: null },
|
||||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
{ 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
|
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
|
||||||
// legt sie sich über einen Knopf über die Seite
|
// legt sie sich über einen Knopf über die Seite
|
||||||
const filterOffen = ref(false)
|
const filterOffen = ref(false)
|
||||||
const mitFilter = computed(() =>
|
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
|
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
|
||||||
const reiter = ref(null)
|
const reiter = ref(null)
|
||||||
|
|
||||||
@@ -105,10 +137,6 @@ watch(activeTab, async () => {
|
|||||||
leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' })
|
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)
|
const selected = ref(null)
|
||||||
|
|
||||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||||
@@ -193,7 +221,12 @@ onMounted(load)
|
|||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
<span v-if="host" class="host">Host</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -256,15 +289,27 @@ onMounted(load)
|
|||||||
@changed="ladeGesehen"
|
@changed="ladeGesehen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MerklisteTab
|
<div v-else-if="activeTab === 'merkliste'" class="layout">
|
||||||
v-else-if="activeTab === 'merkliste'"
|
<MerklisteTab
|
||||||
:angemeldet="angemeldet"
|
:angemeldet="angemeldet"
|
||||||
:gesehen-ids="gesehenIds"
|
:gesehen-ids="gesehenIds"
|
||||||
:items="merkliste"
|
:items="merkliste"
|
||||||
:ich="ich"
|
:ich="ich"
|
||||||
@open="selected = $event"
|
:ansicht="ansicht"
|
||||||
@changed="ladeMerkliste"
|
: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
|
<WuenscheTab
|
||||||
v-else-if="activeTab === 'wuensche'"
|
v-else-if="activeTab === 'wuensche'"
|
||||||
@@ -275,7 +320,12 @@ onMounted(load)
|
|||||||
@changed="ladeWuensche"
|
@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'" />
|
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||||
|
|
||||||
|
|||||||
631
frontend/src/components/Abend.vue
Normal file
631
frontend/src/components/Abend.vue
Normal 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>
|
||||||
151
frontend/src/components/FilmLogin.vue
Normal file
151
frontend/src/components/FilmLogin.vue
Normal 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>
|
||||||
178
frontend/src/components/FilmSuche.vue
Normal file
178
frontend/src/components/FilmSuche.vue
Normal 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>
|
||||||
212
frontend/src/components/Kommentar.vue
Normal file
212
frontend/src/components/Kommentar.vue
Normal 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>
|
||||||
282
frontend/src/components/MarkdownFeld.vue
Normal file
282
frontend/src/components/MarkdownFeld.vue
Normal 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>
|
||||||
@@ -1,62 +1,59 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import MovieCard from './MovieCard.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
|
ich: { type: Object, default: null },
|
||||||
// Eine eigene Sammlung hat nur, wer in der Runde steht
|
// Eine eigene Sammlung hat nur, wer in der Runde steht
|
||||||
angemeldet: Boolean,
|
angemeldet: Boolean,
|
||||||
ich: { type: Object, default: null },
|
|
||||||
gesehenIds: { type: Set, default: () => new Set() },
|
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
|
// Nach Person gruppiert, die eigene Sammlung zuerst
|
||||||
const gruppen = computed(() => {
|
const gruppen = computed(() => {
|
||||||
const nach = new Map()
|
const nach = new Map()
|
||||||
for (const item of props.items) {
|
for (const item of props.items) {
|
||||||
const schluessel = item.mine ? '\u0000eigene' : item.name || 'Anonym'
|
if (!passt(item)) continue
|
||||||
if (!nach.has(schluessel)) {
|
const name = item.name || 'Anonym'
|
||||||
nach.set(schluessel, {
|
if (props.leute.length && !props.leute.includes(name)) continue
|
||||||
titel: item.mine
|
if (!nach.has(name)) {
|
||||||
? `${props.ich?.name || 'Anonym'} (Deine)`
|
nach.set(name, {
|
||||||
: item.name || 'Anonym',
|
name,
|
||||||
|
titel: item.mine ? `${name} (Deine)` : name,
|
||||||
mine: item.mine,
|
mine: item.mine,
|
||||||
filme: [],
|
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)
|
return [...nach.values()].sort((a, b) => b.mine - a.mine)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Je Sammlung ein eigener Filter. Ohne Auswahl steht alles da.
|
// Die Karte braucht die Kennung des Films, nicht die des Eintrags
|
||||||
const filter = ref({})
|
function alsFilm(item) {
|
||||||
|
return { ...item, id: item.movie_id }
|
||||||
function umschalten(gruppe, wert) {
|
|
||||||
filter.value = {
|
|
||||||
...filter.value,
|
|
||||||
[gruppe.schluessel]: filter.value[gruppe.schluessel] === wert ? null : wert,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function vergessen(item) {
|
||||||
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
|
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
|
||||||
emit('changed')
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function oeffnen(item) {
|
async function oeffnen(film) {
|
||||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
const resp = await fetch(`/api/search/${film.id}`)
|
||||||
if (resp.ok) emit('open', await resp.json())
|
if (resp.ok) emit('open', await resp.json())
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -66,126 +63,48 @@ async function oeffnen(item) {
|
|||||||
<p v-if="!items.length" class="note">
|
<p v-if="!items.length" class="note">
|
||||||
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
|
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
|
||||||
</p>
|
</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>
|
<h3>
|
||||||
{{ gruppe.titel }}
|
{{ gruppe.titel }}
|
||||||
<span class="anzahl">{{ gefiltert(gruppe).length }}</span>
|
<span class="anzahl">{{ gruppe.filme.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>
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid" :class="ansicht">
|
||||||
<article
|
<!-- Der Entfernen-Knopf liegt über der Karte statt darin: eine
|
||||||
v-for="item in gefiltert(gruppe)"
|
Karte ist selbst ein Knopf, und Knöpfe schachtelt man nicht -->
|
||||||
:key="item.id"
|
<div v-for="item in gruppe.filme" :key="item.id" class="halter">
|
||||||
class="karte"
|
<MovieCard
|
||||||
:class="{ gesehen: gesehenIds.has(item.movie_id) }"
|
:movie="alsFilm(item)"
|
||||||
>
|
:ansicht="ansicht"
|
||||||
<button type="button" class="cover" @click="oeffnen(item)">
|
:gesehen="gesehenIds.has(item.movie_id)"
|
||||||
<img
|
@open="oeffnen"
|
||||||
v-if="posterUrl(item.poster_path, 'w342')"
|
/>
|
||||||
:src="posterUrl(item.poster_path, 'w342')"
|
<button
|
||||||
:alt="item.title"
|
v-if="item.mine"
|
||||||
loading="lazy"
|
type="button"
|
||||||
/>
|
class="weg"
|
||||||
<span v-else class="kein-cover">Kein Cover</span>
|
title="Aus deiner Sammlung nehmen"
|
||||||
|
@click="vergessen(item)"
|
||||||
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.grid {
|
.merkliste-tab {
|
||||||
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;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -208,62 +127,58 @@ h3 {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schalter {
|
.grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
margin-left: 0.4rem;
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
overflow: hidden;
|
gap: 1.1rem;
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schalter button {
|
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
|
||||||
padding: 0.2rem 0.6rem;
|
oder die halbe Reihe, falls die schmaler ist. */
|
||||||
color: var(--muted);
|
.grid.details {
|
||||||
font-size: 0.68rem;
|
grid-template-columns: repeat(
|
||||||
font-weight: 600;
|
auto-fill,
|
||||||
letter-spacing: 0.05em;
|
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||||
text-transform: uppercase;
|
);
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schalter button.an {
|
.halter {
|
||||||
color: var(--text);
|
position: relative;
|
||||||
background: var(--bg-soft);
|
min-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weg {
|
.weg {
|
||||||
align-self: flex-start;
|
position: absolute;
|
||||||
margin-top: 0.35rem;
|
top: 0.4rem;
|
||||||
padding: 0.28rem 0.6rem;
|
left: 0.4rem;
|
||||||
color: var(--muted);
|
z-index: 2;
|
||||||
font-size: 0.75rem;
|
display: grid;
|
||||||
border: 1px solid var(--line);
|
place-items: center;
|
||||||
border-radius: 6px;
|
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 {
|
.weg:hover {
|
||||||
color: var(--accent);
|
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 {
|
.note {
|
||||||
@@ -271,4 +186,15 @@ h5:hover {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import FilmLogin from './FilmLogin.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
mine: { type: Object, default: null },
|
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
|
// Firefox zeichnet die Optionsliste eines <select> unter Linux im
|
||||||
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
|
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
|
||||||
const offen = ref(false)
|
const offen = ref(false)
|
||||||
const halter = ref(null)
|
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
|
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) {
|
function daneben(e) {
|
||||||
@@ -50,11 +68,19 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li v-for="u in items" :key="u.id">
|
<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 }}
|
{{ u.name }}
|
||||||
|
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">●</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<FilmLogin
|
||||||
|
v-if="anmeldung"
|
||||||
|
:name="anmeldung.name"
|
||||||
|
:pruefen="pruefen"
|
||||||
|
@abbrechen="anmeldung = null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -114,6 +140,14 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
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) {
|
@media (max-width: 640px), (pointer: coarse) {
|
||||||
.knopf {
|
.knopf {
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.45rem 0.85rem;
|
||||||
|
|||||||
254
frontend/src/components/SammlungFilter.vue
Normal file
254
frontend/src/components/SammlungFilter.vue
Normal 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>
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
<script setup>
|
<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: () => [] },
|
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 neuerName = ref('')
|
||||||
const fehler = 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() {
|
async function anlegen() {
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
const resp = await fetch('/api/users', {
|
const resp = await fetch('/api/users', {
|
||||||
@@ -27,9 +65,14 @@ async function entfernen(user) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="verwaltung-tab">
|
<div class="verwaltung-tab">
|
||||||
<section>
|
<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
|
<input
|
||||||
v-model="neuerName"
|
v-model="neuerName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -41,27 +84,101 @@ async function entfernen(user) {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||||
|
|
||||||
<ul v-if="items.length" class="liste">
|
<ul v-if="liste.length" class="liste">
|
||||||
<li v-for="u in items" :key="u.id">
|
<li v-for="u in liste" :key="u.id">
|
||||||
<span class="name">{{ u.name }}</span>
|
<div class="zeile">
|
||||||
<button
|
<span class="name">{{ u.name }}</span>
|
||||||
type="button"
|
<button
|
||||||
class="weg"
|
v-if="u.geschuetzt"
|
||||||
title="Aus der Runde nehmen"
|
type="button"
|
||||||
@click="entfernen(u)"
|
class="auge"
|
||||||
>
|
:class="{ an: gezeigt[u.id] }"
|
||||||
×
|
:title="gezeigt[u.id] ? 'Film verbergen' : 'Hinterlegten Film zeigen'"
|
||||||
</button>
|
@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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="leer">Noch niemand in der Runde.</p>
|
<p v-else class="leer">Noch niemand in der Runde.</p>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.verwaltung-tab {
|
.verwaltung-tab {
|
||||||
max-width: 26rem;
|
max-width: 34rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -107,14 +224,66 @@ h3 {
|
|||||||
|
|
||||||
.liste li {
|
.liste li {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
|
||||||
padding: 0.55rem 0.85rem;
|
padding: 0.55rem 0.85rem;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
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 {
|
.name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -132,13 +301,117 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leer,
|
.leer,
|
||||||
.fehler {
|
.fehler,
|
||||||
|
.erklaerung {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.erklaerung {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.fehler {
|
.fehler {
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
color: var(--accent);
|
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>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,32 @@
|
|||||||
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht,
|
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht und
|
||||||
// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage,
|
// der Schalter für gesehene Filme — alles im localStorage, alles je
|
||||||
// alles je Browser.
|
// Browser. Der Hostmodus nicht: der hängt an der Sitzung auf dem Server.
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
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 = [
|
const TAB_IDS = [
|
||||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||||
@@ -30,24 +55,45 @@ export function useEinstellungen() {
|
|||||||
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
|
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
|
||||||
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
|
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
|
||||||
|
|
||||||
// Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login —
|
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
|
||||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
|
||||||
const host = ref(localStorage.getItem('filmabend_host') === '1')
|
// 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
|
// Der Host-Link: /?host=<schlüssel>. Nach dem Einlösen fliegt er aus der
|
||||||
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
|
// Adresszeile, damit er nicht im Verlauf oder in einem Screenshot landet.
|
||||||
watch(host, (an) => {
|
async function linkEinloesen() {
|
||||||
const weg = an ? 'merkliste' : 'verwaltung'
|
const adresse = new URL(window.location.href)
|
||||||
if (activeTab.value === weg) activeTab.value = 'netflix'
|
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) {
|
function tastatur(e) {
|
||||||
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
||||||
// je nach System ein anderes Zeichen
|
// je nach System ein anderes Zeichen
|
||||||
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
host.value = !host.value
|
// Ohne freigeschaltete Sitzung gibt es nichts umzuschalten
|
||||||
localStorage.setItem('filmabend_host', host.value ? '1' : '0')
|
if (freigeschaltet.value) anzeige.value = !anzeige.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ export function useMeins({ host }) {
|
|||||||
ich.value = daten.mine
|
ich.value = daten.mine
|
||||||
}
|
}
|
||||||
|
|
||||||
async function michSetzen(id) {
|
// Gibt zurück, ob der Wechsel durchging — bei hinterlegtem Anmeldefilm
|
||||||
await fetch('/api/users/waehlen', {
|
// lehnt der Server ohne die richtige movie_id ab
|
||||||
|
async function michSetzen(id, movieId = null) {
|
||||||
|
const resp = await fetch('/api/users/waehlen', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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
|
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
|
||||||
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
|
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
|
||||||
await ladeRunde()
|
await ladeRunde()
|
||||||
@@ -39,6 +42,7 @@ export function useMeins({ host }) {
|
|||||||
ladeMerkliste()
|
ladeMerkliste()
|
||||||
ladeGesehen()
|
ladeGesehen()
|
||||||
ladeWuensche()
|
ladeWuensche()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
|
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
|
||||||
|
|||||||
78
frontend/src/markdown.js
Normal file
78
frontend/src/markdown.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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('')
|
||||||
|
}
|
||||||
@@ -53,6 +53,55 @@ a {
|
|||||||
color: inherit;
|
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 */
|
/* Unter 16 px zoomt iOS beim Tippen in jedes Feld */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
input,
|
input,
|
||||||
|
|||||||
Reference in New Issue
Block a user