Compare commits
44
Commits
13df86e9fc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071c8a673a | ||
|
|
c940173a13 | ||
|
|
b3b6715d1c | ||
|
|
0ab3d53023 | ||
|
|
5e34cb4406 | ||
|
|
93fe2219b9 | ||
|
|
3f12b0742d | ||
|
|
9fdfe27fb9 | ||
|
|
e415c3ac0d | ||
|
|
2f771a9c30 | ||
|
|
b65f4d75f4 | ||
|
|
bd1694528a | ||
|
|
8ddd8208f5 | ||
|
|
09ae21c4b2 | ||
|
|
817f39a42c | ||
|
|
fd7893b5d2 | ||
|
|
624df7e7ee | ||
|
|
ae5b1d0b1d | ||
|
|
70b37054ba | ||
|
|
c589e389df | ||
|
|
7bf9667007 | ||
|
|
be59afd5e2 | ||
|
|
997c681103 | ||
|
|
f80b8c5826 | ||
|
|
f7c9017946 | ||
|
|
a90044b0a7 | ||
|
|
db77c1f985 | ||
|
|
33b3975b61 | ||
|
|
ea54a86db4 | ||
|
|
a1684cf54f | ||
|
|
b42cf64782 | ||
|
|
cb78225e86 | ||
|
|
bfaf0313c1 | ||
|
|
a639e40a23 | ||
|
|
90fffbe077 | ||
|
|
aa5c64ae23 | ||
|
|
7191388787 | ||
|
|
d51c160ce6 | ||
|
|
3dc091d04a | ||
|
|
847b747870 | ||
|
|
2bd03000e8 | ||
|
|
446edc85fb | ||
|
|
00cb299f31 | ||
|
|
08c2e8f885 |
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
TMDB_TOKEN=
|
TMDB_TOKEN=
|
||||||
MINIMAX_KEY=
|
MINIMAX_KEY=
|
||||||
MTX_SENDEPASSWORT=
|
MTX_SENDEPASSWORT=
|
||||||
|
HOST_SCHLUESSEL=
|
||||||
@@ -6,3 +6,10 @@ node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
backend/static/
|
backend/static/
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
backend/backups/
|
||||||
|
data/backups/
|
||||||
|
|
||||||
|
# Aufnahmen der Abende und geschnittene Clips — groß, nicht ins Git
|
||||||
|
aufnahmen/
|
||||||
|
clips/
|
||||||
+1
-1
@@ -23,4 +23,4 @@ VOLUME /data
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "2"]
|
||||||
@@ -1,27 +1,37 @@
|
|||||||
.PHONY: install dev stop sync build prod logs down push pull stream stream-local stream-stop mtx mtx-stop
|
.PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop test
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
|
|
||||||
|
test:
|
||||||
|
cd backend && .venv/bin/python -m pytest -q
|
||||||
|
|
||||||
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
|
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
|
||||||
# Leere und OBS findet keinen Server.
|
# Leere und OBS findet keinen Server.
|
||||||
|
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst
|
||||||
|
# den Namen, und "docker ps" sieht ihn nicht.
|
||||||
mtx:
|
mtx:
|
||||||
@docker ps --filter name=horror-mtx-dev --format '{{.Names}}' | grep -q . || \
|
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
||||||
docker run -d --name horror-mtx-dev --network host \
|
@docker run -d --rm --name horror-mtx-dev --network host \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
|
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
|
||||||
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
|
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
|
||||||
bluenviron/mediamtx:latest > /dev/null
|
-v "$(PWD)/aufnahmen:/recordings" \
|
||||||
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890"
|
bluenviron/mediamtx:latest > /dev/null
|
||||||
|
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890, Aufnahme in aufnahmen/"
|
||||||
|
|
||||||
mtx-stop:
|
mtx-stop:
|
||||||
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
||||||
@echo "MediaMTX gestoppt."
|
@echo "MediaMTX gestoppt."
|
||||||
|
|
||||||
|
# Startet unabhängig davon, was gerade läuft. Die eckige Klammer im Muster
|
||||||
|
# sorgt dafür, dass pkill nicht die eigene Zeile trifft.
|
||||||
dev: mtx
|
dev: mtx
|
||||||
|
-@pkill -f "[u]vicorn app.main:app" 2>/dev/null
|
||||||
|
-@pkill -f "[v]ite --port 5173" 2>/dev/null
|
||||||
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
||||||
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
|
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 --timeout-graceful-shutdown 2 &
|
||||||
@cd frontend && npx vite --port 5173
|
@cd frontend && npx vite --port 5173
|
||||||
|
|
||||||
stop: mtx-stop stream-stop
|
stop: mtx-stop stream-stop
|
||||||
@@ -37,10 +47,16 @@ build:
|
|||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
# ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
|
# ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
|
||||||
|
# --force-recreate: auch MediaMTX kommt neu hoch und liest die
|
||||||
|
# mediamtx.yml frisch — sonst bleibt eine Konfigänderung ohne Wirkung
|
||||||
prod:
|
prod:
|
||||||
docker compose up -d --build
|
docker compose up -d --build --force-recreate
|
||||||
@echo "horror läuft: https://horror.marha.de"
|
@echo "horror läuft: https://horror.marha.de"
|
||||||
|
|
||||||
|
# Der Sync läuft im Container, ein backend/.venv gibt es dort nicht
|
||||||
|
sync-prod:
|
||||||
|
docker compose exec horror python -m app.sync
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
@@ -54,8 +70,10 @@ ZIEL ?= horror.marha.de
|
|||||||
|
|
||||||
# Räumt vorher auf: ein noch laufendes OBS belegt sonst die Sendestrecke,
|
# Räumt vorher auf: ein noch laufendes OBS belegt sonst die Sendestrecke,
|
||||||
# und der Tonweg wird sauber neu aufgebaut.
|
# und der Tonweg wird sauber neu aufgebaut.
|
||||||
|
# Der Stream bekommt den vollen Ton, dieser Rechner bleibt still.
|
||||||
|
# Zum Mitschauen am selben Gerät: make stream MITHOEREN=1
|
||||||
stream: stream-stop
|
stream: stream-stop
|
||||||
@scripts/stream.sh $(ZIEL)
|
@MITHOEREN=$(MITHOEREN) scripts/stream.sh $(ZIEL)
|
||||||
|
|
||||||
# Zum Ausprobieren ohne Server, gegen das lokale MediaMTX
|
# Zum Ausprobieren ohne Server, gegen das lokale MediaMTX
|
||||||
stream-local: mtx
|
stream-local: mtx
|
||||||
@@ -66,7 +84,7 @@ stream-stop:
|
|||||||
-@scripts/ton.sh ab
|
-@scripts/ton.sh ab
|
||||||
@echo "OBS beendet."
|
@echo "OBS beendet."
|
||||||
|
|
||||||
# ── Datenbank hin und her (lokal ausführen) ───────────────────────────────────
|
# ── Datenbank vom Server holen (lokal ausführen) ───────────────────────────────────
|
||||||
SERVER = root@178.104.67.87
|
SERVER = root@178.104.67.87
|
||||||
REMOTE = /var/www/horror
|
REMOTE = /var/www/horror
|
||||||
LOKAL = backend/horror.db
|
LOKAL = backend/horror.db
|
||||||
@@ -75,14 +93,6 @@ LOKAL = backend/horror.db
|
|||||||
# in sich stimmige Kopie ziehen, statt die Datei direkt anzufassen.
|
# in sich stimmige Kopie ziehen, statt die Datei direkt anzufassen.
|
||||||
SICHERN = import sqlite3,sys; q=sqlite3.connect(sys.argv[1]); z=sqlite3.connect(sys.argv[2]); q.backup(z); z.close(); q.close()
|
SICHERN = import sqlite3,sys; q=sqlite3.connect(sys.argv[1]); z=sqlite3.connect(sys.argv[2]); q.backup(z); z.close(); q.close()
|
||||||
|
|
||||||
push:
|
|
||||||
@echo "Überschreibt die Datenbank auf dem Server, auch die Vorschläge."
|
|
||||||
backend/.venv/bin/python -c '$(SICHERN)' $(LOKAL) /tmp/horror-push.db
|
|
||||||
ssh $(SERVER) 'mkdir -p $(REMOTE)/data && [ ! -f $(REMOTE)/data/horror.db ] || cp $(REMOTE)/data/horror.db $(REMOTE)/data/horror.db.bak'
|
|
||||||
scp /tmp/horror-push.db $(SERVER):$(REMOTE)/data/horror.db
|
|
||||||
@rm -f /tmp/horror-push.db
|
|
||||||
@echo "Fertig. Die alte Datenbank liegt als data/horror.db.bak daneben."
|
|
||||||
|
|
||||||
pull:
|
pull:
|
||||||
@echo "Überschreibt deine lokale Datenbank."
|
@echo "Überschreibt deine lokale Datenbank."
|
||||||
ssh $(SERVER) 'python3 -c "$(SICHERN)" $(REMOTE)/data/horror.db /tmp/horror-pull.db'
|
ssh $(SERVER) 'python3 -c "$(SICHERN)" $(REMOTE)/data/horror.db /tmp/horror-pull.db'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -56,19 +87,19 @@ make down
|
|||||||
|
|
||||||
Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild.
|
Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild.
|
||||||
|
|
||||||
### Datenbank abgleichen
|
### Datenbank vom Server holen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make push # lokaler Stand → Server
|
|
||||||
make pull # Server-Stand → lokal
|
make pull # Server-Stand → lokal
|
||||||
```
|
```
|
||||||
|
|
||||||
Beide ziehen erst eine in sich stimmige Kopie über SQLites `backup`, statt die
|
Zieht erst eine in sich stimmige Kopie über SQLites `backup`, statt die Datei
|
||||||
Datei einer laufenden App direkt zu kopieren. Die überschriebene Datenbank
|
einer laufenden App direkt zu kopieren. Die überschriebene lokale Datenbank
|
||||||
bleibt als `.bak` daneben liegen.
|
bleibt als `.bak` daneben liegen.
|
||||||
|
|
||||||
`make push` überschreibt auch die Vorschläge auf dem Server. Wer schon einen
|
Den umgekehrten Weg gibt es absichtlich nicht: Die Sammlungen, Vorschläge und
|
||||||
Film fürs Rad eingereicht hat, verliert ihn.
|
Wertungen auf dem Server sind die echten. Ein lokaler Stand würde sie
|
||||||
|
überschreiben.
|
||||||
|
|
||||||
Ohne Docker geht es auch:
|
Ohne Docker geht es auch:
|
||||||
|
|
||||||
@@ -183,6 +214,12 @@ Ohne Passwort weist MediaMTX die Verbindung ab. Zuschauen braucht keins.
|
|||||||
das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und
|
das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und
|
||||||
Firefox gar keinen.
|
Firefox gar keinen.
|
||||||
|
|
||||||
|
Der sendende Rechner bleibt dabei still. Der Ton läuft über eine Null-Senke,
|
||||||
|
deren Monitor OBS abgreift; auf die Lautsprecher geht nichts. Am Pegel im
|
||||||
|
Stream ändert das nichts, weil PipeWire die Monitor-Ports vom Regler der
|
||||||
|
Senke unberührt lässt. `make stream MITHOEREN=1` legt den Ton zusätzlich auf
|
||||||
|
dein gewohntes Gerät.
|
||||||
|
|
||||||
**Warum alle dasselbe Bild sehen**
|
**Warum alle dasselbe Bild sehen**
|
||||||
|
|
||||||
Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`).
|
Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`).
|
||||||
@@ -213,6 +250,35 @@ Bild — damit lässt sich der Gleichlauf im Betrieb prüfen.
|
|||||||
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
|
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
|
||||||
MediaMTX davor.
|
MediaMTX davor.
|
||||||
|
|
||||||
|
## Aufnahme und Clips
|
||||||
|
|
||||||
|
MediaMTX schneidet jeden Abend mit: ohne Neukodierung, als fMP4 in
|
||||||
|
30-Sekunden-Segmenten unter `aufnahmen/` (im Container `/recordings`,
|
||||||
|
in `docker-compose.yml` als Volume). Gelöscht wird nichts von selbst —
|
||||||
|
wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf
|
||||||
|
eine Storage Box. Rund 5 GB je Abend bei 1080p.
|
||||||
|
|
||||||
|
Löschen kann der Host in der Verwaltung, Stück für Stück: die App
|
||||||
|
schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
|
||||||
|
`sender` mit dem Sendepasswort), die die Segmente wegräumt.
|
||||||
|
|
||||||
|
Jede Aufnahme hat ihren Film: der Host weist ihn in der Verwaltung zu,
|
||||||
|
sonst gilt der Abend mit dem gleichen Datum, sonst läuft sie unter
|
||||||
|
„Sonstige". Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C
|
||||||
|
(oder der Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor
|
||||||
|
jetzt, weil der Player so weit hinter der Live-Kante hängt. Der Film ist
|
||||||
|
der der laufenden Aufnahme, sonst der zuletzt gezogene. „Clip erstellen"
|
||||||
|
im Reiter „Clips" setzt eine Marke an den Anfang einer beliebigen
|
||||||
|
Aufnahme. Dort zieht man das Fenster um die Marke, sieht es sich an und
|
||||||
|
schneidet; die MP4 liegt dann unter `clips/`. Jeder sieht nur seine
|
||||||
|
Clips, der Host alle — er räumt auch auf.
|
||||||
|
|
||||||
|
Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
|
||||||
|
nur intern): `/get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4`.
|
||||||
|
Er fügt Segmente selbst zusammen und liefert auch aus dem gerade
|
||||||
|
laufenden Segment. Die App reicht das als Vorschau durch und legt beim
|
||||||
|
Schneiden die Datei ab; ffmpeg braucht es dafür nicht.
|
||||||
|
|
||||||
## Vorschläge und Rad
|
## Vorschläge und Rad
|
||||||
|
|
||||||
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Nächtliche Kopie der Datenbank.
|
||||||
|
|
||||||
|
Sammlungen, Wertungen und Wünsche liegen nur in dieser einen Datei. Die
|
||||||
|
Kopie entsteht über SQLites eigene Backup-Schnittstelle — die ist auch
|
||||||
|
dann konsistent, wenn gerade jemand schreibt. Es bleiben die letzten
|
||||||
|
vierzehn Stände, jeder Tag einer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from .config import DB_PATH
|
||||||
|
|
||||||
|
log = logging.getLogger("app.backup")
|
||||||
|
|
||||||
|
BEHALTEN = 14
|
||||||
|
|
||||||
|
|
||||||
|
def sichern():
|
||||||
|
ordner = DB_PATH.parent / "backups"
|
||||||
|
ordner.mkdir(parents=True, exist_ok=True)
|
||||||
|
ziel = ordner / f"{DB_PATH.stem}-{date.today():%Y-%m-%d}{DB_PATH.suffix}"
|
||||||
|
|
||||||
|
quelle = sqlite3.connect(DB_PATH)
|
||||||
|
kopie = sqlite3.connect(ziel)
|
||||||
|
try:
|
||||||
|
quelle.backup(kopie)
|
||||||
|
finally:
|
||||||
|
kopie.close()
|
||||||
|
quelle.close()
|
||||||
|
|
||||||
|
# Die Namen sortieren nach Datum, das Alte steht vorn
|
||||||
|
alte = sorted(ordner.glob(f"{DB_PATH.stem}-*{DB_PATH.suffix}"))[:-BEHALTEN]
|
||||||
|
for alt in alte:
|
||||||
|
alt.unlink()
|
||||||
|
|
||||||
|
log.info("Datenbank gesichert: %s", ziel.name)
|
||||||
|
return ziel
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Clips aus der Aufnahme des Abends.
|
||||||
|
|
||||||
|
Eine Marke ist erst nur ein Zeitpunkt: Taste C, fertig, nichts lenkt ab.
|
||||||
|
Wann immer man will, zieht man ein Fenster darum, sieht es sich an und
|
||||||
|
schneidet. Dann holt die App den Ausschnitt vom Playback-Server von
|
||||||
|
MediaMTX und legt ihn als MP4 ab. Jeder sieht nur seine eigenen Clips,
|
||||||
|
der Host alle — er räumt auf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import CLIPS_DIR, MEDIAMTX_API_URL, MEDIAMTX_PLAYBACK_URL, MTX_SENDEPASSWORT
|
||||||
|
from .db import connect
|
||||||
|
from .users import WEM, besitzer, gehoert_mir
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS clips (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
movie_id INTEGER,
|
||||||
|
marke_at TEXT NOT NULL,
|
||||||
|
vorher REAL NOT NULL,
|
||||||
|
dauer REAL NOT NULL,
|
||||||
|
titel TEXT,
|
||||||
|
datei TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Der Film einer Aufnahme, vom Host gesetzt. Ohne Eintrag gilt der Abend
|
||||||
|
-- mit dem gleichen Datum — oder keiner.
|
||||||
|
CREATE TABLE IF NOT EXISTS aufnahme_film (
|
||||||
|
start TEXT PRIMARY KEY,
|
||||||
|
movie_id INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
STREAM_PFAD = "filmabend"
|
||||||
|
# Der Player hängt so weit hinter der Live-Kante — die Marke zeigt auf
|
||||||
|
# das, was die Person gerade gesehen hat, nicht auf das, was gerade ankommt
|
||||||
|
PLAYER_LATENZ = 4
|
||||||
|
# Das Fenster um die Marke: Standard und Grenzen
|
||||||
|
VORHER = 30
|
||||||
|
DAUER = 35
|
||||||
|
# Die Marke ist nur ein Anker — das Fenster darf überall auf dem
|
||||||
|
# Zeitstrahl liegen, auch weit nach der Marke (vorher negativ)
|
||||||
|
MAX_VORHER = 4 * 3600
|
||||||
|
MIN_DAUER = 3
|
||||||
|
MAX_DAUER = 180
|
||||||
|
MAX_TITEL = 80
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
with connect() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def _jetzt():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(zeit):
|
||||||
|
return zeit.isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def markieren(session_id, movie_id, zeit=None):
|
||||||
|
"""Eine Marke setzen — vier Sekunden vor jetzt, wegen der Latenz.
|
||||||
|
Oder zu einem gegebenen Zeitpunkt, für Clips aus alten Aufnahmen."""
|
||||||
|
with connect() as conn:
|
||||||
|
clip_id = conn.execute(
|
||||||
|
"INSERT INTO clips (session_id, user_id, movie_id, marke_at, vorher, dauer, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
besitzer(session_id),
|
||||||
|
movie_id,
|
||||||
|
_iso(zeit or _jetzt() - timedelta(seconds=PLAYER_LATENZ)),
|
||||||
|
VORHER,
|
||||||
|
DAUER,
|
||||||
|
_iso(_jetzt()),
|
||||||
|
),
|
||||||
|
).lastrowid
|
||||||
|
# Erst nach dem Commit lesen — sonst sieht die neue Verbindung nichts
|
||||||
|
return holen(clip_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffentlich(row, session_id, ich):
|
||||||
|
clip = {k: v for k, v in dict(row).items() if k not in ("session_id", "user_id", "freigegeben")}
|
||||||
|
clip["geschnitten"] = clip.pop("datei") is not None
|
||||||
|
clip["mine"] = gehoert_mir(row, session_id, ich)
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
def holen(clip_id, session_id):
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
|
||||||
|
"LEFT JOIN users u ON u.id = c.user_id "
|
||||||
|
"LEFT JOIN movies m ON m.id = c.movie_id WHERE c.id = ?",
|
||||||
|
(clip_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _oeffentlich(row, session_id, besitzer(session_id)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def alle(session_id, host=False):
|
||||||
|
"""Alle Clips, für jeden. Ändern darf nur der Besitzer — und der Host,
|
||||||
|
der aufräumt; dafür steht an jedem Clip, ob er einem gehört."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
|
||||||
|
"LEFT JOIN users u ON u.id = c.user_id "
|
||||||
|
"LEFT JOIN movies m ON m.id = c.movie_id "
|
||||||
|
"ORDER BY c.marke_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [_oeffentlich(r, session_id, ich) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(conn, clip_id):
|
||||||
|
return conn.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _darf(row, session_id, host):
|
||||||
|
return row is not None and (host or gehoert_mir(row, session_id, besitzer(session_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def aendern(clip_id, session_id, host, titel=None, vorher=None, dauer=None):
|
||||||
|
"""Titel oder Fenster. Ein neues Fenster macht den Schnitt ungültig —
|
||||||
|
die Datei fliegt, bis neu geschnitten wird."""
|
||||||
|
with connect() as conn:
|
||||||
|
row = _zeile(conn, clip_id)
|
||||||
|
if not _darf(row, session_id, host):
|
||||||
|
return None
|
||||||
|
felder = {}
|
||||||
|
if titel is not None:
|
||||||
|
felder["titel"] = titel.strip()[:MAX_TITEL] or None
|
||||||
|
if vorher is not None:
|
||||||
|
felder["vorher"] = max(-MAX_VORHER, min(MAX_VORHER, float(vorher)))
|
||||||
|
if dauer is not None:
|
||||||
|
felder["dauer"] = max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
|
||||||
|
fenster_neu = any(
|
||||||
|
k in felder and felder[k] != row[k] for k in ("vorher", "dauer")
|
||||||
|
)
|
||||||
|
if fenster_neu and row["datei"]:
|
||||||
|
_datei_weg(row["datei"])
|
||||||
|
felder["datei"] = None
|
||||||
|
if felder:
|
||||||
|
setzt = ", ".join(f"{k} = ?" for k in felder)
|
||||||
|
conn.execute(f"UPDATE clips SET {setzt} WHERE id = ?", (*felder.values(), clip_id))
|
||||||
|
return holen(clip_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def loeschen(clip_id, session_id, host):
|
||||||
|
with connect() as conn:
|
||||||
|
row = _zeile(conn, clip_id)
|
||||||
|
if not _darf(row, session_id, host):
|
||||||
|
return False
|
||||||
|
if row["datei"]:
|
||||||
|
_datei_weg(row["datei"])
|
||||||
|
conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _datei_weg(name):
|
||||||
|
pfad = CLIPS_DIR / name
|
||||||
|
if pfad.exists():
|
||||||
|
pfad.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def fenster(row, vorher=None, dauer=None):
|
||||||
|
"""Start (RFC3339) und Dauer des Ausschnitts um die Marke."""
|
||||||
|
marke = datetime.fromisoformat(row["marke_at"])
|
||||||
|
v = row["vorher"] if vorher is None else max(-MAX_VORHER, min(MAX_VORHER, float(vorher)))
|
||||||
|
d = row["dauer"] if dauer is None else max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
|
||||||
|
return _iso(marke - timedelta(seconds=v)), d
|
||||||
|
|
||||||
|
|
||||||
|
def playback_url(row, vorher=None, dauer=None):
|
||||||
|
start, d = fenster(row, vorher, dauer)
|
||||||
|
return (
|
||||||
|
f"{MEDIAMTX_PLAYBACK_URL}/get",
|
||||||
|
{"path": STREAM_PFAD, "start": start, "duration": d, "format": "mp4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zeile(clip_id, session_id, host):
|
||||||
|
"""Die rohe Zeile zum Ansehen — Clips sieht jeder. Ändern und
|
||||||
|
Löschen prüfen gesondert, ob es der Besitzer oder der Host ist."""
|
||||||
|
with connect() as conn:
|
||||||
|
return _zeile(conn, clip_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def schneiden(clip_id, session_id, host):
|
||||||
|
"""Den Ausschnitt vom Playback-Server holen und als Datei ablegen.
|
||||||
|
Gibt None zurück, wenn der Clip nicht gehört; wirft httpx-Fehler,
|
||||||
|
wenn MediaMTX nicht liefert."""
|
||||||
|
with connect() as conn:
|
||||||
|
row = _zeile(conn, clip_id)
|
||||||
|
if not _darf(row, session_id, host):
|
||||||
|
return None
|
||||||
|
url, params = playback_url(row)
|
||||||
|
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"{clip_id}.mp4"
|
||||||
|
ziel = CLIPS_DIR / name
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as http:
|
||||||
|
async with http.stream("GET", url, params=params) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(ziel, "wb") as f:
|
||||||
|
async for stueck in resp.aiter_bytes():
|
||||||
|
f.write(stueck)
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (name, clip_id))
|
||||||
|
return holen(clip_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Ein Abend dauert ein paar Stunden — so weit reicht der Zeitstrahl
|
||||||
|
SPANNE = 4 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
async def _segmente(von, bis):
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||||
|
resp = await http.get(
|
||||||
|
f"{MEDIAMTX_PLAYBACK_URL}/list",
|
||||||
|
params={"path": STREAM_PFAD, "start": _iso(von), "end": _iso(bis)},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aufnahmen: die Stücke aus MediaMTX, jedes mit seinem Film ──────────
|
||||||
|
# Eine Aufnahme hängt an keinem Abend. Der Host weist ihr einen Film zu;
|
||||||
|
# ohne Zuweisung gilt der Abend mit dem gleichen Datum, sonst keiner.
|
||||||
|
|
||||||
|
|
||||||
|
def _stueck(s):
|
||||||
|
return {
|
||||||
|
"start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))),
|
||||||
|
"dauer": round(s["duration"], 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _abendtag(iso):
|
||||||
|
"""Der Abend reicht bis zum nächsten Mittag — die Nacht gehört dazu."""
|
||||||
|
zeit = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return (zeit - timedelta(hours=12)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def film_zuweisen(start, movie_id):
|
||||||
|
"""Der Host hängt einen Film an eine Aufnahme — oder nimmt ihn weg."""
|
||||||
|
with connect() as conn:
|
||||||
|
if movie_id is None:
|
||||||
|
conn.execute("DELETE FROM aufnahme_film WHERE start = ?", (start,))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO aufnahme_film (start, movie_id) VALUES (?, ?)",
|
||||||
|
(start, movie_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filme(conn):
|
||||||
|
"""Zuweisungen je Start und Abende je Tag — um Aufnahmen ihren Film zu geben."""
|
||||||
|
zugewiesen = {
|
||||||
|
r["start"]: {"id": r["movie_id"], "title": r["title"], "poster_path": r["poster_path"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT a.start, a.movie_id, m.title, m.poster_path FROM aufnahme_film a "
|
||||||
|
"LEFT JOIN movies m ON m.id = a.movie_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
abende = {}
|
||||||
|
for w in conn.execute(
|
||||||
|
"SELECT movie_id, title, poster_path, watched_at FROM watched ORDER BY id"
|
||||||
|
):
|
||||||
|
abende.setdefault(
|
||||||
|
_abendtag(w["watched_at"]),
|
||||||
|
{"id": w["movie_id"], "title": w["title"], "poster_path": w["poster_path"]},
|
||||||
|
)
|
||||||
|
return zugewiesen, abende
|
||||||
|
|
||||||
|
|
||||||
|
def _mit_film(stueck, zugewiesen, abende):
|
||||||
|
film = zugewiesen.get(stueck["start"])
|
||||||
|
return {
|
||||||
|
**stueck,
|
||||||
|
"film": film or abende.get(_abendtag(stueck["start"])),
|
||||||
|
"zugewiesen": film is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def aufnahmen_alle():
|
||||||
|
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film."""
|
||||||
|
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
|
||||||
|
with connect() as conn:
|
||||||
|
zugewiesen, abende = _filme(conn)
|
||||||
|
stuecke = [_mit_film(_stueck(s), zugewiesen, abende) for s in segmente]
|
||||||
|
return sorted(stuecke, key=lambda s: s["start"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def film_der_aufnahme(start):
|
||||||
|
"""Nur die id — für neue Marken."""
|
||||||
|
with connect() as conn:
|
||||||
|
zugewiesen, abende = _filme(conn)
|
||||||
|
film = _mit_film({"start": start}, zugewiesen, abende)["film"]
|
||||||
|
return film["id"] if film else None
|
||||||
|
|
||||||
|
|
||||||
|
async def film_live():
|
||||||
|
"""Der Film der gerade laufenden Aufnahme — für die Taste C. None,
|
||||||
|
wenn nichts aufgenommen wird oder MediaMTX nicht antwortet."""
|
||||||
|
try:
|
||||||
|
segmente = await _segmente(_jetzt() - timedelta(hours=6), _jetzt())
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
for s in segmente:
|
||||||
|
stueck = _stueck(s)
|
||||||
|
ende = datetime.fromisoformat(stueck["start"]) + timedelta(seconds=stueck["dauer"])
|
||||||
|
if ende >= _jetzt() - timedelta(seconds=90):
|
||||||
|
return film_der_aufnahme(stueck["start"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _im_stueck(segmente, start, dauer):
|
||||||
|
"""Die Segmente (aus der API), die zu einem Stück der Aufnahme gehören:
|
||||||
|
ihr Beginn liegt zwischen dessen Start und Ende. Eine Sekunde Luft an
|
||||||
|
beiden Enden — Playback rundet die Zeiten anders als die API."""
|
||||||
|
von = datetime.fromisoformat(start) - timedelta(seconds=1)
|
||||||
|
bis = datetime.fromisoformat(start) + timedelta(seconds=dauer + 1)
|
||||||
|
aus = []
|
||||||
|
for s in segmente:
|
||||||
|
beginn = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
||||||
|
if von <= beginn <= bis:
|
||||||
|
aus.append(s["start"])
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
async def aufnahme_loeschen(start, dauer):
|
||||||
|
"""Ein Stück der Aufnahme löschen — MediaMTX räumt die Segmente weg.
|
||||||
|
Gibt zurück, wie viele es waren."""
|
||||||
|
auth = ("sender", MTX_SENDEPASSWORT)
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, auth=auth) as http:
|
||||||
|
resp = await http.get(f"{MEDIAMTX_API_URL}/v3/recordings/get/{STREAM_PFAD}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
starts = _im_stueck(resp.json().get("segments") or [], start, dauer)
|
||||||
|
for s in starts:
|
||||||
|
resp = await http.delete(
|
||||||
|
f"{MEDIAMTX_API_URL}/v3/recordings/deletesegment",
|
||||||
|
params={"path": STREAM_PFAD, "start": s},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return len(starts)
|
||||||
|
|
||||||
|
|
||||||
|
async def aufnahme(um, spanne=SPANNE):
|
||||||
|
"""Welche Stücke der Aufnahme es um einen Zeitpunkt herum gibt — für
|
||||||
|
den Zeitstrahl im Editor. Relativ zur Marke, in Sekunden."""
|
||||||
|
marke = datetime.fromisoformat(um)
|
||||||
|
segmente = await _segmente(marke - timedelta(seconds=spanne), marke + timedelta(seconds=spanne))
|
||||||
|
aus = []
|
||||||
|
for s in segmente:
|
||||||
|
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
||||||
|
von = (start - marke).total_seconds()
|
||||||
|
aus.append({"von": round(von, 1), "bis": round(von + s["duration"], 1)})
|
||||||
|
return aus
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -20,6 +25,14 @@ PROVIDER_NETFLIX = 8
|
|||||||
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
|
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
|
||||||
# Dienstnamen, lokal auf localhost
|
# Dienstnamen, lokal auf localhost
|
||||||
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
|
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
|
||||||
|
# Der Playback-Server von MediaMTX: liefert Ausschnitte der Aufnahme
|
||||||
|
MEDIAMTX_PLAYBACK_URL = os.getenv("MEDIAMTX_PLAYBACK_URL", "http://127.0.0.1:9996")
|
||||||
|
# Die Steuer-API von MediaMTX, zum Löschen von Aufnahmen. Angemeldet
|
||||||
|
# wird sich als der Sender-Nutzer mit dem Sendepasswort aus der .env.
|
||||||
|
MEDIAMTX_API_URL = os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997")
|
||||||
|
MTX_SENDEPASSWORT = os.getenv("MTX_SENDEPASSWORT", "")
|
||||||
|
# Wohin die geschnittenen Clips kommen
|
||||||
|
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips"))
|
||||||
REGION = "DE"
|
REGION = "DE"
|
||||||
LANGUAGE = "de-DE"
|
LANGUAGE = "de-DE"
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS movies (
|
|||||||
netflix_id TEXT,
|
netflix_id TEXT,
|
||||||
netflix_url TEXT,
|
netflix_url TEXT,
|
||||||
on_netflix INTEGER DEFAULT 0,
|
on_netflix INTEGER DEFAULT 0,
|
||||||
|
rentable INTEGER,
|
||||||
canon_group INTEGER,
|
canon_group INTEGER,
|
||||||
canon_pos INTEGER,
|
canon_pos INTEGER,
|
||||||
collection_id INTEGER,
|
collection_id INTEGER,
|
||||||
@@ -77,6 +78,7 @@ def init():
|
|||||||
("on_netflix", "INTEGER DEFAULT 0"),
|
("on_netflix", "INTEGER DEFAULT 0"),
|
||||||
("canon_group", "INTEGER"),
|
("canon_group", "INTEGER"),
|
||||||
("canon_pos", "INTEGER"),
|
("canon_pos", "INTEGER"),
|
||||||
|
("rentable", "INTEGER"),
|
||||||
):
|
):
|
||||||
if column not in existing:
|
if column not in existing:
|
||||||
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
|
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
|
||||||
@@ -116,10 +118,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)
|
||||||
|
|||||||
+13
-1
@@ -19,16 +19,26 @@ log = logging.getLogger("events")
|
|||||||
PUFFER = 16
|
PUFFER = 16
|
||||||
|
|
||||||
_abonnenten: set[asyncio.Queue] = set()
|
_abonnenten: set[asyncio.Queue] = set()
|
||||||
|
# Welche Sitzung hinter einer Verbindung steckt — daraus ergibt sich,
|
||||||
|
# wer gerade online ist: eine offene Seite mit gewähltem Namen
|
||||||
|
_sitzungen: dict[asyncio.Queue, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def anmelden():
|
def anmelden(session_id=None):
|
||||||
queue = asyncio.Queue(maxsize=PUFFER)
|
queue = asyncio.Queue(maxsize=PUFFER)
|
||||||
_abonnenten.add(queue)
|
_abonnenten.add(queue)
|
||||||
|
if session_id:
|
||||||
|
_sitzungen[queue] = session_id
|
||||||
return queue
|
return queue
|
||||||
|
|
||||||
|
|
||||||
def abmelden(queue):
|
def abmelden(queue):
|
||||||
_abonnenten.discard(queue)
|
_abonnenten.discard(queue)
|
||||||
|
_sitzungen.pop(queue, None)
|
||||||
|
|
||||||
|
|
||||||
|
def sitzungen_online():
|
||||||
|
return set(_sitzungen.values())
|
||||||
|
|
||||||
|
|
||||||
def anzahl():
|
def anzahl():
|
||||||
@@ -60,3 +70,5 @@ async def stream(queue, request):
|
|||||||
yield ": ping\n\n"
|
yield ": ping\n\n"
|
||||||
finally:
|
finally:
|
||||||
abmelden(queue)
|
abmelden(queue)
|
||||||
|
# Die anderen Seiten sollen den grünen Punkt ausmachen
|
||||||
|
senden("users")
|
||||||
+156
-16
@@ -1,18 +1,48 @@
|
|||||||
"""Was der Seite noch fehlt.
|
"""Was der Seite noch fehlt.
|
||||||
|
|
||||||
Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche,
|
Jeder darf aufschreiben, was er sich wünscht, und für Wünsche anderer
|
||||||
ändern darf jeder nur die eigenen — wie bei den Filmsammlungen.
|
stimmen — eine Stimme je Person. Ergänzungen hängen als Anmerkungen unter
|
||||||
|
dem Wunsch, damit der ursprüngliche Text stehen bleibt und die Diskussion
|
||||||
|
nachvollziehbar ist. Der Host hakt ab, was umgesetzt ist: der Wunsch bleibt
|
||||||
|
sichtbar, rutscht aber ans Ende.
|
||||||
|
|
||||||
|
Ändern darf jeder nur die eigenen, wie bei den Filmsammlungen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import users
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
from .users import WEM, besitzer, gehoert_mir
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS features (
|
CREATE TABLE IF NOT EXISTS features (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
done_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
feature_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Eigenes Statement, weil users.je_person() die Tabelle daraus neu bauen
|
||||||
|
# könnte — hier ohne UNIQUE in der Spaltendefinition, die Eindeutigkeit
|
||||||
|
# kommt aus den Indizes je Person.
|
||||||
|
VOTES_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
feature_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
@@ -20,24 +50,74 @@ CREATE TABLE IF NOT EXISTS features (
|
|||||||
MAX_LAENGE = 500
|
MAX_LAENGE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _jetzt():
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
conn.executescript(VOTES_SCHEMA)
|
||||||
|
users.user_id_nachruesten(conn, "features")
|
||||||
|
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
|
||||||
|
spalten = {r[1] for r in conn.execute("PRAGMA table_info(features)")}
|
||||||
|
if "done_at" not in spalten:
|
||||||
|
conn.execute("ALTER TABLE features ADD COLUMN done_at TEXT")
|
||||||
|
# Eine Stimme je Person und Wunsch
|
||||||
|
users.je_person(conn, "feature_votes", VOTES_SCHEMA, "feature_id")
|
||||||
|
|
||||||
|
|
||||||
def alle(session_id):
|
def alle(session_id):
|
||||||
"""Alle Wünsche, jeder mit dem Namen seines Verfassers."""
|
"""Alle Wünsche: offene nach Stimmen, erledigte dahinter.
|
||||||
|
|
||||||
|
Jeder Wunsch bringt den Namen des Verfassers, die Stimmenzahl, die
|
||||||
|
eigene Stimme und seine Anmerkungen mit.
|
||||||
|
"""
|
||||||
|
ich = besitzer(session_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT f.*, u.name FROM features f "
|
"SELECT f.*, u.name, "
|
||||||
"LEFT JOIN user_sessions s ON s.session_id = f.session_id "
|
" (SELECT COUNT(*) FROM feature_votes v WHERE v.feature_id = f.id) "
|
||||||
"LEFT JOIN users u ON u.id = s.user_id "
|
" AS votes "
|
||||||
"ORDER BY f.created_at DESC"
|
"FROM features f "
|
||||||
|
"LEFT JOIN users u ON u.id = f.user_id "
|
||||||
|
"ORDER BY f.done_at IS NOT NULL, votes DESC, f.created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
meine_stimmen = {
|
||||||
|
r["feature_id"]
|
||||||
|
for r in conn.execute(
|
||||||
|
f"SELECT feature_id FROM feature_votes WHERE {WEM}",
|
||||||
|
(ich, ich, session_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notizen = conn.execute(
|
||||||
|
"SELECT n.*, u.name FROM feature_notes n "
|
||||||
|
"LEFT JOIN users u ON u.id = n.user_id "
|
||||||
|
"ORDER BY n.created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
je_wunsch = {}
|
||||||
|
for n in notizen:
|
||||||
|
je_wunsch.setdefault(n["feature_id"], []).append(
|
||||||
|
{
|
||||||
|
"id": n["id"],
|
||||||
|
"text": n["text"],
|
||||||
|
"name": n["name"],
|
||||||
|
"created_at": n["created_at"],
|
||||||
|
"mine": gehoert_mir(n, session_id, ich),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
**{k: v for k, v in dict(r).items() if k != "session_id"},
|
**{
|
||||||
"mine": r["session_id"] == session_id,
|
k: v
|
||||||
|
for k, v in dict(r).items()
|
||||||
|
if k not in ("session_id", "user_id")
|
||||||
|
},
|
||||||
|
"mine": gehoert_mir(r, session_id, ich),
|
||||||
|
"voted": r["id"] in meine_stimmen,
|
||||||
|
"notes": je_wunsch.get(r["id"], []),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -50,12 +130,9 @@ def anlegen(session_id, text):
|
|||||||
return None
|
return None
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)",
|
"INSERT INTO features (session_id, user_id, text, created_at) "
|
||||||
(
|
"VALUES (?, ?, ?, ?)",
|
||||||
session_id,
|
(session_id, besitzer(session_id), text, _jetzt()),
|
||||||
text,
|
|
||||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return alle(session_id)
|
return alle(session_id)
|
||||||
|
|
||||||
@@ -71,10 +148,73 @@ def aendern(feature_id, text):
|
|||||||
).rowcount
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def erledigen(feature_id, erledigt):
|
||||||
|
"""Der Host hakt ab — oder holt einen Wunsch wieder hervor."""
|
||||||
|
with connect() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"UPDATE features SET done_at = ? WHERE id = ?",
|
||||||
|
(_jetzt() if erledigt else None, feature_id),
|
||||||
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def abstimmen(feature_id, session_id):
|
||||||
|
"""Stimme setzen oder zurücknehmen. Gibt None zurück, wenn es den
|
||||||
|
Wunsch nicht gibt, sonst ob die Stimme jetzt steht."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
weg = conn.execute(
|
||||||
|
f"DELETE FROM feature_votes WHERE feature_id = ? AND {WEM}",
|
||||||
|
(feature_id, ich, ich, session_id),
|
||||||
|
).rowcount
|
||||||
|
if weg:
|
||||||
|
return False
|
||||||
|
da = conn.execute(
|
||||||
|
"SELECT 1 FROM features WHERE id = ?", (feature_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not da:
|
||||||
|
return None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO feature_votes (feature_id, session_id, user_id, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(feature_id, session_id, ich, _jetzt()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def anmerken(feature_id, session_id, text):
|
||||||
|
"""Eine Ergänzung unter den Wunsch hängen."""
|
||||||
|
text = (text or "").strip()[:MAX_LAENGE]
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
with connect() as conn:
|
||||||
|
da = conn.execute(
|
||||||
|
"SELECT 1 FROM features WHERE id = ?", (feature_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not da:
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO feature_notes "
|
||||||
|
"(feature_id, session_id, user_id, text, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(feature_id, session_id, besitzer(session_id), text, _jetzt()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def anmerkung_loeschen(note_id):
|
||||||
|
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem Host."""
|
||||||
|
with connect() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"DELETE FROM feature_notes WHERE id = ?", (note_id,)
|
||||||
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
def loeschen(feature_id):
|
def loeschen(feature_id):
|
||||||
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem
|
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem
|
||||||
Verfasser und dem Host."""
|
Verfasser und dem Host. Stimmen und Anmerkungen gehen mit."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM feature_votes WHERE feature_id = ?", (feature_id,))
|
||||||
|
conn.execute("DELETE FROM feature_notes WHERE feature_id = ?", (feature_id,))
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"DELETE FROM features WHERE id = ?", (feature_id,)
|
"DELETE FROM features WHERE id = ?", (feature_id,)
|
||||||
).rowcount
|
).rowcount
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Filmvorschläge von MiniMax.
|
||||||
|
|
||||||
|
Das Modell kennt Filme nur aus seinem Training — keine Datenbank, kein
|
||||||
|
Netz. Es bekommt eine Beschreibung und auf Wunsch die eigene Sammlung,
|
||||||
|
nennt Titel mit Jahr und einem Satz Begründung. Jeder Titel wird danach
|
||||||
|
bei TMDB nachgeschlagen. Was es dort nicht gibt, fällt weg: so bleiben
|
||||||
|
erfundene oder verwechselte Filme draußen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import search
|
||||||
|
from .config import LANGUAGE, MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
|
||||||
|
from .teaser import THINK_BLOCK
|
||||||
|
|
||||||
|
ANZAHL = 12
|
||||||
|
MAX_ANZAHL = 100
|
||||||
|
MAX_BESCHREIBUNG = 1000
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
|
||||||
|
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
|
||||||
|
"Nenne genau {anzahl} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
|
||||||
|
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
|
||||||
|
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
|
||||||
|
"Mische Bekanntes mit Geheimtipps. "
|
||||||
|
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
|
||||||
|
'{{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
|
||||||
|
'"kandidaten": ["{kandidaten} Originaltitel mit Jahr, die dazu passen könnten"], '
|
||||||
|
'"filme": [{{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}}]}}\n'
|
||||||
|
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
|
||||||
|
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
|
||||||
|
"Erst merkmale, dann kandidaten, dann die {anzahl} besten davon als filme.\n"
|
||||||
|
# Die Begründung lesen Leute, die einfach einen Film für den Abend
|
||||||
|
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
|
||||||
|
"Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer "
|
||||||
|
"Schrift, für Laien. "
|
||||||
|
"Sag klar, was der Film mit dem Gesuchten gemeinsam hat. "
|
||||||
|
"Keine Fachwörter wie Whodunit, Neo-Noir, Ensemble, Slasher, Body-Horror, "
|
||||||
|
"Meta, Hommage, Genre-Namen auf Englisch. Keine Inhaltsangabe, kein Titel, "
|
||||||
|
"kein Land. Ein Regisseur nur, wenn er der Grund ist. "
|
||||||
|
"Jede Begründung nennt einen anderen Punkt, keine zwei gleich. "
|
||||||
|
'Beispiel — gesucht "Ich mochte Knives Out": '
|
||||||
|
'"Wieder rätselt eine zerstrittene Familie um einen Mord, mit Witz."'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auf der Karte hat die Begründung vier Zeilen à gut zwanzig Zeichen
|
||||||
|
MAX_GRUND = 95
|
||||||
|
|
||||||
|
|
||||||
|
def kuerzen(grund):
|
||||||
|
"""Das Modell zählt ungenau. Was nicht auf die Karte passt, endet am
|
||||||
|
letzten Komma oder Wort davor — ein halber Satz ist besser als ein
|
||||||
|
abgeschnittener."""
|
||||||
|
grund = grund.strip()
|
||||||
|
if len(grund) <= MAX_GRUND:
|
||||||
|
return grund
|
||||||
|
kopf = grund[:MAX_GRUND]
|
||||||
|
schnitt = max(kopf.rfind(","), kopf.rfind(";"), kopf.rfind(" –"), kopf.rfind("."))
|
||||||
|
if schnitt < 40:
|
||||||
|
schnitt = kopf.rfind(" ")
|
||||||
|
return kopf[:schnitt].rstrip(",; –.") + " …"
|
||||||
|
|
||||||
|
|
||||||
|
def system(anzahl):
|
||||||
|
"""Der Systemprompt für eine gewünschte Anzahl Filme. Die Kandidaten-
|
||||||
|
liste bleibt ein Stück länger als das Ergebnis."""
|
||||||
|
return SYSTEM.format(anzahl=anzahl, kandidaten=max(20, anzahl + 8))
|
||||||
|
|
||||||
|
|
||||||
|
def etikett(film):
|
||||||
|
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
|
||||||
|
jahr = film.get("year")
|
||||||
|
return f"{film['title']} ({jahr})" if jahr else film["title"]
|
||||||
|
|
||||||
|
|
||||||
|
def _spanne(unten, oben, einheit="", ab="mindestens", bis="höchstens"):
|
||||||
|
if unten is not None and oben is not None:
|
||||||
|
return f"zwischen {unten}{einheit} und {oben}{einheit}"
|
||||||
|
if unten is not None:
|
||||||
|
return f"{ab} {unten}{einheit}"
|
||||||
|
if oben is not None:
|
||||||
|
return f"{bis} {oben}{einheit}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def vorgaben(filter_):
|
||||||
|
"""Die Filterleiste als Sätze für den Prompt. Nur was verstellt ist,
|
||||||
|
kommt vor — die Leiste schickt auch nur das."""
|
||||||
|
f = filter_ or {}
|
||||||
|
zeilen = []
|
||||||
|
if f.get("include"):
|
||||||
|
zeilen.append("Genre: " + ", ".join(f["include"]))
|
||||||
|
if f.get("exclude"):
|
||||||
|
zeilen.append("Auf keinen Fall: " + ", ".join(f["exclude"]))
|
||||||
|
for text in (
|
||||||
|
_spanne(f.get("jahr_min"), f.get("jahr_max"), ab="ab", bis="bis"),
|
||||||
|
_spanne(f.get("note_min"), f.get("note_max"), " von 10 auf TMDB"),
|
||||||
|
_spanne(f.get("dauer_min"), f.get("dauer_max"), " Minuten"),
|
||||||
|
_spanne(f.get("stimmen_min"), f.get("stimmen_max"), " Stimmen auf TMDB"),
|
||||||
|
):
|
||||||
|
if text:
|
||||||
|
zeilen.append(text[0].upper() + text[1:])
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(beschreibung, sammlung, gesehen, filter_=None):
|
||||||
|
teile = []
|
||||||
|
if beschreibung:
|
||||||
|
teile.append(f"Gesucht: {beschreibung}")
|
||||||
|
# In der Nutzernachricht hält sich das Modell eher daran als im
|
||||||
|
# Systemprompt — sonst steht die Fortsetzung des Gesuchten vorn
|
||||||
|
teile.append(
|
||||||
|
"Nicht vorschlagen: Filme, die oben genannt sind, und ihre Fortsetzungen."
|
||||||
|
)
|
||||||
|
regeln = vorgaben(filter_)
|
||||||
|
if regeln:
|
||||||
|
teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln))
|
||||||
|
if sammlung:
|
||||||
|
teile.append("Meine Sammlung, so etwas mag ich:\n" + "\n".join(f"- {t}" for t in sammlung))
|
||||||
|
if not beschreibung and sammlung:
|
||||||
|
teile.append("Schlag vor, was zu dieser Sammlung passt.")
|
||||||
|
elif not beschreibung:
|
||||||
|
teile.append("Schlag vor, was zu den Vorgaben passt.")
|
||||||
|
if gesehen:
|
||||||
|
teile.append("Schon gesehen, nicht vorschlagen:\n" + "\n".join(f"- {t}" for t in gesehen))
|
||||||
|
return "\n\n".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def parsen(text, anzahl=ANZAHL):
|
||||||
|
"""Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
|
||||||
|
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
|
||||||
|
und der letzten Klammer steht."""
|
||||||
|
text = THINK_BLOCK.sub("", text)
|
||||||
|
a, b = text.find("{"), text.rfind("}")
|
||||||
|
if a < 0 or b < a:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
daten = json.loads(text[a : b + 1])
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
filme = daten.get("filme") if isinstance(daten, dict) else None
|
||||||
|
aus = []
|
||||||
|
for f in filme or []:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
titel = str(f.get("titel") or "").strip()
|
||||||
|
if not titel:
|
||||||
|
continue
|
||||||
|
jahr = str(f.get("jahr") or "")
|
||||||
|
aus.append(
|
||||||
|
{
|
||||||
|
"titel": titel,
|
||||||
|
"jahr": int(jahr) if jahr.isdigit() else None,
|
||||||
|
"grund": kuerzen(str(f.get("grund") or "")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return aus[:anzahl]
|
||||||
|
|
||||||
|
|
||||||
|
async def fragen(beschreibung, sammlung, gesehen, filter_=None, anzahl=ANZAHL):
|
||||||
|
payload = {
|
||||||
|
"model": MINIMAX_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system(anzahl)},
|
||||||
|
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
|
||||||
|
],
|
||||||
|
# Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
|
||||||
|
# und liefert am Token-Limit gar nichts. Die Merkmale- und
|
||||||
|
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
|
||||||
|
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
# Rund 40 Token je Film, dazu Merkmale und Kandidaten
|
||||||
|
"max_tokens": 2000 + 60 * anzahl,
|
||||||
|
"temperature": 0.3,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {MINIMAX_KEY}"}, timeout=120.0
|
||||||
|
) as http:
|
||||||
|
# Hin und wieder kommt kein brauchbares JSON zurück. Ein zweiter
|
||||||
|
# Anlauf kostet wenige Sekunden und sitzt fast immer.
|
||||||
|
for _ in range(2):
|
||||||
|
resp = await http.post(MINIMAX_URL, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
filme = parsen(resp.json()["choices"][0]["message"]["content"], anzahl)
|
||||||
|
if filme:
|
||||||
|
return filme
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _aehnlich(gesucht, treffer):
|
||||||
|
"""Passt der TMDB-Treffer zum genannten Titel? Die Suche liefert
|
||||||
|
auch Streifschüsse — "The Afterparty" findet ein Winx-Special. Der
|
||||||
|
deutsche oder der Originaltitel muss dem Genannten nahekommen."""
|
||||||
|
soll = search.ohne_zeichen(gesucht).lower()
|
||||||
|
return any(
|
||||||
|
SequenceMatcher(None, soll, search.ohne_zeichen(t or "").lower()).ratio() >= 0.6
|
||||||
|
for t in (treffer.get("title"), treffer.get("original_title"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dauer_passt(karte, filter_):
|
||||||
|
dauer = karte.get("runtime")
|
||||||
|
if dauer is None:
|
||||||
|
return True
|
||||||
|
unten, oben = filter_.get("dauer_min"), filter_.get("dauer_max")
|
||||||
|
return (unten is None or dauer >= unten) and (oben is None or dauer <= oben)
|
||||||
|
|
||||||
|
|
||||||
|
async def aufloesen(vorschlaege, filter_=None, ausser=()):
|
||||||
|
"""Jeden Titel bei TMDB suchen. Der Treffer mit dem genannten Jahr
|
||||||
|
gewinnt — das Modell liegt gern ein Jahr daneben, also mit Spiel.
|
||||||
|
Sonst der beliebteste. Ohne Treffer war der Film wohl erfunden.
|
||||||
|
|
||||||
|
Die Filter der Leiste greifen hier noch einmal hart: was die KI
|
||||||
|
trotz Vorgabe danebenlegt, sieben die TMDB-Daten aus."""
|
||||||
|
filter_ = filter_ or {}
|
||||||
|
|
||||||
|
async def einer(http, v):
|
||||||
|
try:
|
||||||
|
resp = await http.get(
|
||||||
|
"/search/movie",
|
||||||
|
params={"query": v["titel"], "language": LANGUAGE, "include_adult": "false"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
treffer = [
|
||||||
|
t for t in resp.json().get("results", [])
|
||||||
|
if t.get("poster_path")
|
||||||
|
and _aehnlich(v["titel"], t)
|
||||||
|
and search.passt_zur_leiste(t, filter_)
|
||||||
|
]
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
if v["jahr"]:
|
||||||
|
passend = [
|
||||||
|
t for t in treffer
|
||||||
|
if (t.get("release_date") or "")[:4].isdigit()
|
||||||
|
and abs(int(t["release_date"][:4]) - v["jahr"]) <= 1
|
||||||
|
]
|
||||||
|
treffer = passend or treffer
|
||||||
|
if not treffer:
|
||||||
|
return None
|
||||||
|
bester = max(treffer, key=lambda t: t.get("popularity") or 0)
|
||||||
|
return {**search.as_result(bester), "grund": v["grund"]}
|
||||||
|
|
||||||
|
async with search.client() as http:
|
||||||
|
gefunden = await asyncio.gather(*(einer(http, v) for v in vorschlaege))
|
||||||
|
karten, dabei = [], set(ausser)
|
||||||
|
for k in gefunden:
|
||||||
|
if k and k["id"] not in dabei:
|
||||||
|
dabei.add(k["id"])
|
||||||
|
karten.append(k)
|
||||||
|
await search.details_nachladen(http, karten)
|
||||||
|
# Die Laufzeit kennt erst die Detailantwort
|
||||||
|
return [k for k in karten if _dauer_passt(k, filter_)]
|
||||||
+719
-21
@@ -1,19 +1,27 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
import httpx
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
backup,
|
||||||
|
clips,
|
||||||
|
punkte,
|
||||||
db,
|
db,
|
||||||
events,
|
events,
|
||||||
features,
|
features,
|
||||||
|
ki,
|
||||||
search,
|
search,
|
||||||
|
spenden,
|
||||||
streamproxy,
|
streamproxy,
|
||||||
suggestions,
|
suggestions,
|
||||||
sync,
|
sync,
|
||||||
@@ -21,7 +29,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.
|
||||||
@@ -69,6 +77,11 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sicherung():
|
||||||
|
# Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread
|
||||||
|
await asyncio.to_thread(backup.sichern)
|
||||||
|
|
||||||
|
|
||||||
async def guarded_sync():
|
async def guarded_sync():
|
||||||
if sync_lock.locked():
|
if sync_lock.locked():
|
||||||
log.info("Sync läuft bereits")
|
log.info("Sync läuft bereits")
|
||||||
@@ -80,16 +93,30 @@ async def guarded_sync():
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
db.init()
|
db.init()
|
||||||
|
users.init()
|
||||||
suggestions.init()
|
suggestions.init()
|
||||||
watched.init()
|
watched.init()
|
||||||
users.init()
|
clips.init()
|
||||||
wishlist.init()
|
wishlist.init()
|
||||||
features.init()
|
features.init()
|
||||||
|
spenden.init()
|
||||||
|
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
|
||||||
|
# ein sauberer Stand
|
||||||
|
if not HOST_SCHLUESSEL:
|
||||||
|
log.warning(
|
||||||
|
"HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu."
|
||||||
|
)
|
||||||
|
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
|
||||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
scheduler.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,
|
||||||
|
# und Filme, bei denen noch nicht steht, ob man sie leihen kann
|
||||||
|
asyncio.create_task(sync.katalog_nachtragen())
|
||||||
|
asyncio.create_task(sync.leihbarkeit_nachtragen())
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
@@ -138,15 +165,16 @@ def movie(movie_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
async def search_movies(q: str):
|
async def search_movies(q: str, limit: int = 24):
|
||||||
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
||||||
if len(q.strip()) < 2:
|
if len(q.strip()) < 2:
|
||||||
return []
|
return []
|
||||||
return await search.search(q.strip())
|
return await search.search(q.strip(), limit=max(1, min(search.MAX_TREFFER, limit)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/discover")
|
@app.get("/api/discover")
|
||||||
async def stoebern(
|
async def stoebern(
|
||||||
|
limit: int = 40,
|
||||||
sort: str = "rating",
|
sort: str = "rating",
|
||||||
include: str = "",
|
include: str = "",
|
||||||
exclude: str = "",
|
exclude: str = "",
|
||||||
@@ -173,7 +201,8 @@ async def stoebern(
|
|||||||
"jahr_max": jahr_max,
|
"jahr_max": jahr_max,
|
||||||
"note_min": note_min,
|
"note_min": note_min,
|
||||||
"note_max": note_max,
|
"note_max": note_max,
|
||||||
}
|
},
|
||||||
|
limit=max(1, min(search.MAX_TREFFER, limit)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +243,277 @@ async def aehnliche_filme(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ki-suche")
|
||||||
|
async def ki_suche(
|
||||||
|
response: Response,
|
||||||
|
beschreibung: str = Body("", embed=True),
|
||||||
|
mit_sammlung: bool = Body(False, embed=True),
|
||||||
|
ohne_gesehene: bool = Body(True, embed=True),
|
||||||
|
include: str = Body("", embed=True),
|
||||||
|
exclude: str = Body("", embed=True),
|
||||||
|
stimmen_min: int | None = Body(None, embed=True),
|
||||||
|
stimmen_max: int | None = Body(None, embed=True),
|
||||||
|
dauer_min: int | None = Body(None, embed=True),
|
||||||
|
dauer_max: int | None = Body(None, embed=True),
|
||||||
|
jahr_min: int | None = Body(None, embed=True),
|
||||||
|
jahr_max: int | None = Body(None, embed=True),
|
||||||
|
note_min: float | None = Body(None, embed=True),
|
||||||
|
note_max: float | None = Body(None, embed=True),
|
||||||
|
limit: int = Body(ki.ANZAHL, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
|
||||||
|
geht auf Wunsch mit, die gesehenen Filme als Ausschluss. Die Filter
|
||||||
|
der Leiste sind Vorgaben an die KI und danach ein Sieb."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
filter_ = {
|
||||||
|
"include": [g for g in include.split(",") if g],
|
||||||
|
"exclude": [g for g in exclude.split(",") if g],
|
||||||
|
"stimmen_min": stimmen_min,
|
||||||
|
"stimmen_max": stimmen_max,
|
||||||
|
"dauer_min": dauer_min,
|
||||||
|
"dauer_max": dauer_max,
|
||||||
|
"jahr_min": jahr_min,
|
||||||
|
"jahr_max": jahr_max,
|
||||||
|
"note_min": note_min,
|
||||||
|
"note_max": note_max,
|
||||||
|
}
|
||||||
|
beschreibung = beschreibung.strip()[: ki.MAX_BESCHREIBUNG]
|
||||||
|
sammlung = (
|
||||||
|
[ki.etikett(e) for e in wishlist.alle(sid) if e["mine"]] if mit_sammlung else []
|
||||||
|
)
|
||||||
|
if not beschreibung and not sammlung and not ki.vorgaben(filter_):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Beschreib, was du suchst, oder gib die Sammlung mit."
|
||||||
|
)
|
||||||
|
gesehen = watched.alle(sid)
|
||||||
|
try:
|
||||||
|
vorschlaege = await ki.fragen(
|
||||||
|
beschreibung,
|
||||||
|
sammlung,
|
||||||
|
[ki.etikett(g) for g in gesehen],
|
||||||
|
filter_,
|
||||||
|
max(1, min(ki.MAX_ANZAHL, limit)),
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, KeyError, IndexError):
|
||||||
|
raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
|
||||||
|
ausser = {g["movie_id"] for g in gesehen} if ohne_gesehene else set()
|
||||||
|
return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Clips: Marken setzen, Fenster ziehen, schneiden, freigeben ──────────
|
||||||
|
def clip_oder_404(clip):
|
||||||
|
if clip is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/clips")
|
||||||
|
async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
|
||||||
|
laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
|
||||||
|
Abend unter Gesehen."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
movie_id = await clips.film_live()
|
||||||
|
gezogen = letzter_dreh["ergebnis"]
|
||||||
|
if movie_id is None and gezogen:
|
||||||
|
movie_id = gezogen["gewinner"]["movie_id"]
|
||||||
|
if movie_id is None:
|
||||||
|
abend = watched.juengster()
|
||||||
|
movie_id = abend["movie_id"] if abend else None
|
||||||
|
clip = clips.markieren(sid, movie_id)
|
||||||
|
events.senden("clips")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/aufnahmen")
|
||||||
|
async def aufnahmen_liste():
|
||||||
|
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
|
||||||
|
„Clip erstellen" und die Verwaltung."""
|
||||||
|
try:
|
||||||
|
return {"stuecke": await clips.aufnahmen_alle()}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/aufnahmen/film")
|
||||||
|
async def aufnahme_film_setzen(
|
||||||
|
response: Response,
|
||||||
|
start: str = Body(..., embed=True),
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Der Host sagt, welcher Film in einer Aufnahme lief — oder nimmt es zurück."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
if movie_id is not None:
|
||||||
|
# Suchtreffer stehen nicht im Katalog — Titel und Poster müssen aber her
|
||||||
|
await film_holen(movie_id)
|
||||||
|
clips.film_zuweisen(start, movie_id)
|
||||||
|
return {"zugewiesen": movie_id is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/aufnahmen")
|
||||||
|
async def aufnahme_loeschen(
|
||||||
|
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
|
||||||
|
):
|
||||||
|
"""Ein Stück der Aufnahme löschen — nur der Host."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
try:
|
||||||
|
anzahl = await clips.aufnahme_loeschen(start, dauer)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail=f"MediaMTX lehnt ab ({e.response.status_code})."
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
|
||||||
|
clips.film_zuweisen(start, None)
|
||||||
|
return {"segmente": anzahl}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/clips/von-aufnahme")
|
||||||
|
def clip_von_aufnahme(
|
||||||
|
response: Response,
|
||||||
|
start: str = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
|
||||||
|
Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
|
||||||
|
die Aufnahme mit — oder keinen."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
beginn = datetime.fromisoformat(start)
|
||||||
|
clip = clips.markieren(
|
||||||
|
sid, clips.film_der_aufnahme(start), beginn + timedelta(seconds=clips.VORHER + 30)
|
||||||
|
)
|
||||||
|
events.senden("clips")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/clips")
|
||||||
|
def clips_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
return clips.alle(sid, users.ist_host(sid))
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/clips/{clip_id}")
|
||||||
|
def clip_aendern(
|
||||||
|
clip_id: int,
|
||||||
|
response: Response,
|
||||||
|
titel: str | None = Body(None, embed=True),
|
||||||
|
vorher: float | None = Body(None, embed=True),
|
||||||
|
dauer: float | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
clip = clip_oder_404(clips.aendern(clip_id, sid, users.ist_host(sid), titel, vorher, dauer))
|
||||||
|
events.senden("clips")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/clips/{clip_id}")
|
||||||
|
def clip_loeschen(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not clips.loeschen(clip_id, sid, users.ist_host(sid)):
|
||||||
|
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
|
||||||
|
events.senden("clips")
|
||||||
|
return {"removed": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/clips/{clip_id}/schneiden")
|
||||||
|
async def clip_schneiden(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Den Ausschnitt aus der Aufnahme als Datei ablegen."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
try:
|
||||||
|
clip = clip_oder_404(await clips.schneiden(clip_id, sid, users.ist_host(sid)))
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
|
||||||
|
events.senden("clips")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/clips/{clip_id}/aufnahme")
|
||||||
|
async def clip_aufnahme(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Die Stücke der Aufnahme rund um die Marke, für den Zeitstrahl."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
|
||||||
|
try:
|
||||||
|
return await clips.aufnahme(row["marke_at"])
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/clips/{clip_id}/vorschau")
|
||||||
|
async def clip_vorschau(
|
||||||
|
clip_id: int,
|
||||||
|
response: Response,
|
||||||
|
vorher: float | None = None,
|
||||||
|
dauer: float | None = None,
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Ein Fenster um die Marke ansehen, ohne zu schneiden — beim Ziehen
|
||||||
|
der Regler. Kommt direkt vom Playback-Server durch."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
|
||||||
|
url, params = clips.playback_url(row, vorher, dauer)
|
||||||
|
http = httpx.AsyncClient(timeout=120.0)
|
||||||
|
try:
|
||||||
|
antwort = await http.send(http.build_request("GET", url, params=params), stream=True)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
await http.aclose()
|
||||||
|
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
|
||||||
|
if antwort.status_code != 200:
|
||||||
|
await antwort.aclose()
|
||||||
|
await http.aclose()
|
||||||
|
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
|
||||||
|
|
||||||
|
async def weiterreichen():
|
||||||
|
try:
|
||||||
|
async for stueck in antwort.aiter_bytes():
|
||||||
|
yield stueck
|
||||||
|
finally:
|
||||||
|
await antwort.aclose()
|
||||||
|
await http.aclose()
|
||||||
|
|
||||||
|
return StreamingResponse(weiterreichen(), media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/clips/{clip_id}/video")
|
||||||
|
def clip_video(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
|
||||||
|
if not row["datei"] or not (clips.CLIPS_DIR / row["datei"]).exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Noch nicht geschnitten")
|
||||||
|
return FileResponse(clips.CLIPS_DIR / row["datei"], media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/movies/{movie_id}/credits")
|
||||||
|
async def film_credits(movie_id: int):
|
||||||
|
"""Regie und Besetzung, live von TMDB."""
|
||||||
|
return await search.credits(movie_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/personen")
|
||||||
|
async def personen_suchen(q: str):
|
||||||
|
"""Schauspieler und Regisseure bei TMDB suchen."""
|
||||||
|
if len(q.strip()) < 2:
|
||||||
|
return []
|
||||||
|
return await search.personen(q.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/personen/{person_id}/filme")
|
||||||
|
async def personen_filme(person_id: int):
|
||||||
|
"""Was jemand gespielt hat und wo er Regie geführt hat."""
|
||||||
|
try:
|
||||||
|
return await search.person_filme(person_id)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="TMDB antwortet gerade nicht.")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/search/{movie_id}")
|
@app.get("/api/search/{movie_id}")
|
||||||
async def search_detail(movie_id: int):
|
async def search_detail(movie_id: int):
|
||||||
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
||||||
@@ -241,13 +541,35 @@ def sitzung(response: Response, sid):
|
|||||||
return neu
|
return neu
|
||||||
|
|
||||||
|
|
||||||
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
|
def mitglied(sid):
|
||||||
letzter_dreh = {"ergebnis": None, "start": 0.0}
|
"""Wer keinen Namen gewählt hat, darf zusehen, aber nichts eintragen.
|
||||||
|
|
||||||
|
Vorschläge, Sammlungen, Wertungen und Wünsche gehören einer Person.
|
||||||
|
Ohne Namen hingen sie nur am Cookie: im geteilten Browser schriebe
|
||||||
|
jeder auf dasselbe Konto, und nach dem Löschen der Cookies wäre alles
|
||||||
|
weg. Die Knöpfe blendet die Oberfläche aus — das hier ist der Riegel.
|
||||||
|
|
||||||
|
Der Host braucht dafür ebenfalls einen Namen. Seine eigenen Knöpfe
|
||||||
|
(drehen, eintragen, aufräumen) laufen über andere Routen.
|
||||||
|
"""
|
||||||
|
ich = users.besitzer(sid)
|
||||||
|
if ich is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Dafür musst du oben deinen Namen wählen."
|
||||||
|
)
|
||||||
|
return ich
|
||||||
|
|
||||||
|
|
||||||
|
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen
|
||||||
|
# kann — samt Anwesenheit der Runde, die beim Eintragen am Abend landet
|
||||||
|
letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def ereignisse(request: Request):
|
async def ereignisse(request: Request, filmabend_sid: str | None = Cookie(None)):
|
||||||
queue = events.anmelden()
|
queue = events.anmelden(filmabend_sid)
|
||||||
|
# Die anderen Seiten sollen den grünen Punkt anmachen
|
||||||
|
events.senden("users")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
events.stream(queue, request),
|
events.stream(queue, request),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
@@ -260,7 +582,7 @@ def drehen():
|
|||||||
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
||||||
laeuft = (
|
laeuft = (
|
||||||
letzter_dreh["ergebnis"]
|
letzter_dreh["ergebnis"]
|
||||||
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
|
and time.monotonic() - letzter_dreh["start"] < letzter_dreh["ergebnis"]["dauer"]
|
||||||
)
|
)
|
||||||
if laeuft:
|
if laeuft:
|
||||||
return {"started": False, "reason": "dreht bereits"}
|
return {"started": False, "reason": "dreht bereits"}
|
||||||
@@ -271,6 +593,7 @@ def drehen():
|
|||||||
|
|
||||||
letzter_dreh["ergebnis"] = ergebnis
|
letzter_dreh["ergebnis"] = ergebnis
|
||||||
letzter_dreh["start"] = time.monotonic()
|
letzter_dreh["start"] = time.monotonic()
|
||||||
|
letzter_dreh["teilnehmer"] = suggestions.teilnehmer()
|
||||||
events.senden("spin", ergebnis)
|
events.senden("spin", ergebnis)
|
||||||
return {"started": True, **ergebnis}
|
return {"started": True, **ergebnis}
|
||||||
|
|
||||||
@@ -283,7 +606,7 @@ def letzter():
|
|||||||
return {"running": False, "result": None}
|
return {"running": False, "result": None}
|
||||||
vergangen = time.monotonic() - letzter_dreh["start"]
|
vergangen = time.monotonic() - letzter_dreh["start"]
|
||||||
return {
|
return {
|
||||||
"running": vergangen < suggestions.DREH_DAUER,
|
"running": vergangen < ergebnis["dauer"],
|
||||||
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
||||||
"elapsed": round(vergangen, 2),
|
"elapsed": round(vergangen, 2),
|
||||||
"result": ergebnis,
|
"result": ergebnis,
|
||||||
@@ -294,18 +617,34 @@ def letzter():
|
|||||||
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
eintraege = suggestions.alle()
|
eintraege = suggestions.alle()
|
||||||
|
ich = users.besitzer(sid)
|
||||||
|
dabei = suggestions.dabei_alle()
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{k: v for k, v in e.items() if k != "session_id"} for e in eintraege
|
{k: v for k, v in e.items() if k not in ("session_id", "user_id")}
|
||||||
|
for e in eintraege
|
||||||
],
|
],
|
||||||
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
|
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
|
||||||
"mine": next(
|
"mine": next(
|
||||||
(e["id"] for e in eintraege if e["session_id"] == sid),
|
(e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
# Wer ohne Vorschlag dabei ist
|
||||||
|
"dabei": [d["name"] or "Anonym" for d in dabei],
|
||||||
|
"dabei_mine": any(users.gehoert_mir(d, sid, ich) for d in dabei),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dabei")
|
||||||
|
def dabei(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Dabei ohne Vorschlag — an oder wieder aus."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
steht = suggestions.dabei_umschalten(sid)
|
||||||
|
events.senden("suggestions")
|
||||||
|
return {"dabei": steht}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/suggestions")
|
@app.post("/api/suggestions")
|
||||||
async def vorschlagen(
|
async def vorschlagen(
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -313,16 +652,20 @@ async def vorschlagen(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
movie = await film_holen(movie_id)
|
movie = await film_holen(movie_id)
|
||||||
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
||||||
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
|
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
|
||||||
events.senden("suggestions")
|
events.senden("suggestions")
|
||||||
return {k: v for k, v in eintrag.items() if k != "session_id"}
|
return {
|
||||||
|
k: v for k, v in eintrag.items() if k not in ("session_id", "user_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/suggestions")
|
@app.delete("/api/suggestions")
|
||||||
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
entfernt = suggestions.loeschen(sid)
|
entfernt = suggestions.loeschen(sid)
|
||||||
if entfernt:
|
if entfernt:
|
||||||
events.senden("suggestions")
|
events.senden("suggestions")
|
||||||
@@ -330,14 +673,101 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── Info: Bezahlen und Regeln ───────────────────────────────────────────
|
||||||
|
INFO_MAX = 8000
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/info")
|
||||||
|
def info():
|
||||||
|
"""Ein Markdown-Text des Hosts: PayPal, IBAN, Regeln, was man wissen
|
||||||
|
muss. Solange keiner da ist, steht die Vorlage mit den Punkteregeln."""
|
||||||
|
return {"text": db.get_meta("info_text") or punkte.vorlage()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/info")
|
||||||
|
def info_setzen(
|
||||||
|
response: Response,
|
||||||
|
text: str = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.set_meta(conn, "info_text", text.strip()[:INFO_MAX])
|
||||||
|
events.senden("info")
|
||||||
|
return {"text": db.get_meta("info_text")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Spendenbaum ─────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/spenden")
|
||||||
|
def spenden_liste():
|
||||||
|
return spenden.alle()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/spenden")
|
||||||
|
def spende_eintragen(
|
||||||
|
response: Response,
|
||||||
|
user_id: int = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
spenden.eintragen(user_id)
|
||||||
|
events.senden("spenden")
|
||||||
|
return spenden.alle()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kontostand")
|
||||||
|
def kontostand():
|
||||||
|
"""Was auf dem Filmabend-Konto liegt — der Host trägt die Zahl ein."""
|
||||||
|
wert = db.get_meta("kontostand")
|
||||||
|
return {"betrag": float(wert) if wert else None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/kontostand")
|
||||||
|
def kontostand_setzen(
|
||||||
|
response: Response,
|
||||||
|
betrag: float | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.set_meta(conn, "kontostand", "" if betrag is None else round(betrag, 2))
|
||||||
|
events.senden("spenden")
|
||||||
|
return kontostand()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/spenden/{user_id}")
|
||||||
|
def spende_entfernen(
|
||||||
|
user_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
if spenden.entfernen(user_id):
|
||||||
|
events.senden("spenden")
|
||||||
|
return spenden.alle()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/features")
|
@app.get("/api/features")
|
||||||
@@ -353,6 +783,7 @@ def wunsch_anlegen(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
liste = features.anlegen(sid, text)
|
liste = features.anlegen(sid, text)
|
||||||
if liste is None:
|
if liste is None:
|
||||||
raise HTTPException(status_code=400, detail="Kein Text")
|
raise HTTPException(status_code=400, detail="Kein Text")
|
||||||
@@ -376,6 +807,56 @@ def wunsch_loeschen(feature_id: int):
|
|||||||
return {"removed": entfernt}
|
return {"removed": entfernt}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/features/{feature_id}/vote")
|
||||||
|
def wunsch_abstimmen(
|
||||||
|
feature_id: int,
|
||||||
|
response: Response,
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Ein Klick setzt die Stimme, der nächste nimmt sie zurück."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
steht = features.abstimmen(feature_id, sid)
|
||||||
|
if steht is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
|
||||||
|
events.senden("features")
|
||||||
|
return {"voted": steht}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/features/{feature_id}/done")
|
||||||
|
def wunsch_erledigen(feature_id: int, done: bool = Body(..., embed=True)):
|
||||||
|
"""Der Host hakt ab — den Knopf sieht nur er."""
|
||||||
|
if not features.erledigen(feature_id, done):
|
||||||
|
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
|
||||||
|
events.senden("features")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/features/{feature_id}/notes")
|
||||||
|
def wunsch_anmerken(
|
||||||
|
feature_id: int,
|
||||||
|
response: Response,
|
||||||
|
text: str = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
if not features.anmerken(feature_id, sid, text):
|
||||||
|
raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt")
|
||||||
|
events.senden("features")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Eigener Pfad statt /api/features/notes/{id}: der stünde sonst mit
|
||||||
|
# /api/features/{feature_id} im Weg
|
||||||
|
@app.delete("/api/feature-notes/{note_id}")
|
||||||
|
def anmerkung_loeschen(note_id: int):
|
||||||
|
entfernt = features.anmerkung_loeschen(note_id)
|
||||||
|
if entfernt:
|
||||||
|
events.senden("features")
|
||||||
|
return {"removed": entfernt}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/wishlist")
|
@app.get("/api/wishlist")
|
||||||
def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
"""Was diese Sitzung sich vorgemerkt hat."""
|
"""Was diese Sitzung sich vorgemerkt hat."""
|
||||||
@@ -390,6 +871,7 @@ async def merken(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
return wishlist.merken(sid, await film_holen(movie_id))
|
return wishlist.merken(sid, await film_holen(movie_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -400,13 +882,23 @@ def vergessen(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
return {"removed": wishlist.vergessen(sid, movie_id)}
|
return {"removed": wishlist.vergessen(sid, movie_id)}
|
||||||
|
|
||||||
|
|
||||||
@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)}
|
staende = punkte.alle()
|
||||||
|
leer = punkte.stand(0)
|
||||||
|
online = users.online_ids(events.sitzungen_online())
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{**u, **staende.get(u["id"], leer), "online": u["id"] in online} for u in users.alle()
|
||||||
|
],
|
||||||
|
"mine": users.aktuell(sid),
|
||||||
|
"host": users.ist_host(sid),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/users")
|
@app.post("/api/users")
|
||||||
@@ -427,37 +919,208 @@ 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)
|
||||||
|
# Ob es einen Host-Film gibt, darf jeder wissen — nur nicht welchen.
|
||||||
|
# Ohne Film hat die Tastenkombination nichts zu fragen.
|
||||||
|
return {"host": users.ist_host(sid), "film": users.host_film() is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/host")
|
||||||
|
def host_modus(
|
||||||
|
response: Response,
|
||||||
|
an: bool = Body(..., embed=True),
|
||||||
|
schluessel: str | None = Body(None, embed=True),
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
|
||||||
|
|
||||||
|
Freischalten geht mit dem Schlüssel aus der .env, einmal über
|
||||||
|
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
|
||||||
|
fragt danach, wie beim Anmeldefilm eines Namens. Ist kein Host-Film
|
||||||
|
gesetzt, reicht die Tastenkombination allein.
|
||||||
|
|
||||||
|
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)}
|
||||||
|
|
||||||
|
film = users.host_film()
|
||||||
|
darf = (
|
||||||
|
users.ist_host(sid)
|
||||||
|
or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL))
|
||||||
|
or film is None
|
||||||
|
or movie_id == film
|
||||||
|
)
|
||||||
|
if not darf:
|
||||||
|
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
|
||||||
|
return {"host": users.host_setzen(sid, True)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/host/film")
|
||||||
|
async def host_film_zeigen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Welcher Film den Hostmodus öffnet — nur der Host sieht ihn."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
movie_id = users.host_film()
|
||||||
|
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/host/film")
|
||||||
|
def host_film_setzen(
|
||||||
|
response: Response,
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Den Host-Film setzen oder aufheben — nur als Host."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
users.host_film_setzen(movie_id)
|
||||||
|
return {"gesetzt": movie_id is not None}
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
events.senden("users")
|
||||||
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")
|
||||||
|
events.senden("users")
|
||||||
return {"mine": benutzer}
|
return {"mine": benutzer}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/watched")
|
@app.get("/api/watched")
|
||||||
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
return watched.alle(sid)
|
return watched.alle(sid, users.ist_host(sid))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/watched")
|
@app.post("/api/watched")
|
||||||
async def als_gesehen(movie_id: int = Body(..., embed=True)):
|
async def als_gesehen(movie_id: int = Body(..., embed=True)):
|
||||||
"""Der Host trägt ein, was am Abend lief."""
|
"""Der Host trägt ein, was am Abend lief. Ist es der gezogene Film,
|
||||||
|
hängt die Runde vom Rad als Anwesenheit am Abend."""
|
||||||
eintrag = watched.markieren(await film_holen(movie_id))
|
eintrag = watched.markieren(await film_holen(movie_id))
|
||||||
|
gezogen = letzter_dreh["ergebnis"]
|
||||||
|
if gezogen and gezogen["gewinner"]["movie_id"] == movie_id and letzter_dreh["teilnehmer"]:
|
||||||
|
watched.dabei_setzen(eintrag["id"], letzter_dreh["teilnehmer"])
|
||||||
events.senden("watched")
|
events.senden("watched")
|
||||||
return eintrag
|
return eintrag
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/watched/{watched_id}/dabei")
|
||||||
|
def dabei_aendern(watched_id: int, user_ids: list[int] = Body(..., embed=True)):
|
||||||
|
"""Der Host berichtigt, wer dabei war — etwa wer spät kam."""
|
||||||
|
watched.dabei_setzen(watched_id, user_ids)
|
||||||
|
events.senden("watched")
|
||||||
|
return {"dabei": user_ids}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/watched/hearts")
|
||||||
|
def herz_setzen(
|
||||||
|
response: Response,
|
||||||
|
note_id: int = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Ein Klick setzt das Herz, der nächste nimmt es zurück."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
steht = watched.herz(sid, note_id)
|
||||||
|
if steht is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kommentar nicht gefunden")
|
||||||
|
events.senden("watched")
|
||||||
|
return {"hearted": steht}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/watched/{watched_id}")
|
@app.delete("/api/watched/{watched_id}")
|
||||||
def gesehen_entfernen(watched_id: int):
|
def gesehen_entfernen(watched_id: int):
|
||||||
entfernt = watched.entfernen(watched_id)
|
entfernt = watched.entfernen(watched_id)
|
||||||
@@ -492,6 +1155,7 @@ def bewerten(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_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))
|
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
|
||||||
@@ -510,6 +1174,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."""
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Erfahrung und Level — berechnet, nicht gebucht.
|
||||||
|
|
||||||
|
Punkte sind eine Formel über dem, was gerade in der Datenbank steht:
|
||||||
|
wer dabei war, bewertet, kommentiert, geantwortet und ein Herz gegeben
|
||||||
|
hat. Nimmt jemand seine Wertung zurück, sind die Punkte weg. Es gibt
|
||||||
|
kein Konto, das man pflegen oder austricksen könnte.
|
||||||
|
|
||||||
|
Je Film zählt jede Art nur einmal. Level nach Dreieckszahlen: Level L
|
||||||
|
ist erreicht, sobald L·(L−1)/2 Punkte da sind — anfangs schnell, dann
|
||||||
|
stetig langsamer, aber immer in Reichweite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from math import isqrt
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
SCHAUEN = 10
|
||||||
|
BEWERTEN = 3
|
||||||
|
KOMMENTAR = 5
|
||||||
|
ANTWORT = 2
|
||||||
|
HERZ = 1
|
||||||
|
|
||||||
|
# Für den Info-Reiter: was zählt, mit der Zahl — aus denselben Konstanten
|
||||||
|
REGELN = (
|
||||||
|
("Beim Filmabend dabei", SCHAUEN),
|
||||||
|
("Film mit Sternen bewertet", BEWERTEN),
|
||||||
|
("Kommentar geschrieben", KOMMENTAR),
|
||||||
|
("Auf einen Kommentar geantwortet", ANTWORT),
|
||||||
|
("Herz vergeben", HERZ),
|
||||||
|
)
|
||||||
|
|
||||||
|
ZAEHLUNGEN = (
|
||||||
|
(SCHAUEN, "SELECT user_id, COUNT(*) AS n FROM watched_dabei GROUP BY user_id"),
|
||||||
|
(
|
||||||
|
BEWERTEN,
|
||||||
|
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM ratings "
|
||||||
|
"WHERE user_id IS NOT NULL GROUP BY user_id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KOMMENTAR,
|
||||||
|
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
|
||||||
|
"WHERE user_id IS NOT NULL AND parent_id IS NULL GROUP BY user_id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ANTWORT,
|
||||||
|
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
|
||||||
|
"WHERE user_id IS NOT NULL AND parent_id IS NOT NULL GROUP BY user_id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HERZ,
|
||||||
|
"SELECT h.user_id, COUNT(DISTINCT n.watched_id) AS n FROM watched_hearts h "
|
||||||
|
"JOIN watched_notes n ON n.id = h.note_id "
|
||||||
|
"WHERE h.user_id IS NOT NULL GROUP BY h.user_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def vorlage():
|
||||||
|
"""Der Info-Text, solange der Host keinen eigenen geschrieben hat —
|
||||||
|
die Regeln mit ihren Zahlen und die ersten Levelstufen, als Markdown."""
|
||||||
|
zeilen = ["## Erfahrung", ""]
|
||||||
|
zeilen += [f"- {was}: **+{p}**" for was, p in REGELN]
|
||||||
|
zeilen += ["", "Je Film zählt jede Art nur einmal.", "", "## Level", ""]
|
||||||
|
stufen = ", ".join(f"Level {stufe} ab {stufe * (stufe - 1) // 2}" for stufe in range(2, 8))
|
||||||
|
zeilen.append(f"{stufen} … Jedes weitere Level kostet einen Punkt mehr als das davor.")
|
||||||
|
return "\n".join(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
def level(punkte):
|
||||||
|
"""Größtes L mit L·(L−1)/2 ≤ Punkte. Null Punkte sind Level 1."""
|
||||||
|
stufe = (1 + isqrt(1 + 8 * punkte)) // 2
|
||||||
|
while stufe * (stufe - 1) // 2 > punkte:
|
||||||
|
stufe -= 1
|
||||||
|
return max(1, stufe)
|
||||||
|
|
||||||
|
|
||||||
|
def stand(punkte):
|
||||||
|
stufe = level(punkte)
|
||||||
|
return {
|
||||||
|
"punkte": punkte,
|
||||||
|
"level": stufe,
|
||||||
|
# Wo das aktuelle Level begann und wo das nächste anfängt — für den Balken
|
||||||
|
"von": stufe * (stufe - 1) // 2,
|
||||||
|
"bis": stufe * (stufe + 1) // 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def alle():
|
||||||
|
"""Punktestand je user_id."""
|
||||||
|
summen = {}
|
||||||
|
with connect() as conn:
|
||||||
|
for wert, sql in ZAEHLUNGEN:
|
||||||
|
for row in conn.execute(sql):
|
||||||
|
summen[row["user_id"]] = summen.get(row["user_id"], 0) + wert * row["n"]
|
||||||
|
return {user_id: stand(p) for user_id, p in summen.items()}
|
||||||
+177
-19
@@ -7,6 +7,8 @@ landen nicht in der Datenbank.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ from .tmdb import (
|
|||||||
DETAIL_PARAMS,
|
DETAIL_PARAMS,
|
||||||
pick_backdrops,
|
pick_backdrops,
|
||||||
pick_overview,
|
pick_overview,
|
||||||
|
pick_rentable,
|
||||||
pick_title,
|
pick_title,
|
||||||
pick_trailer,
|
pick_trailer,
|
||||||
)
|
)
|
||||||
@@ -66,7 +69,15 @@ async def details_nachladen(http, treffer, je_film=4):
|
|||||||
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
|
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Nicht alle auf einmal: bei einer langen Filmografie wären das
|
||||||
|
# über hundert gleichzeitige Abrufe
|
||||||
|
bremse = asyncio.Semaphore(8)
|
||||||
|
|
||||||
async def hole(film):
|
async def hole(film):
|
||||||
|
async with bremse:
|
||||||
|
await hole_einen(film)
|
||||||
|
|
||||||
|
async def hole_einen(film):
|
||||||
try:
|
try:
|
||||||
resp = await http.get(
|
resp = await http.get(
|
||||||
f"/movie/{film['id']}",
|
f"/movie/{film['id']}",
|
||||||
@@ -105,6 +116,27 @@ SORTIERUNG = {
|
|||||||
|
|
||||||
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
||||||
MAX_SEITE = 20
|
MAX_SEITE = 20
|
||||||
|
# TMDB liefert zwanzig je Seite; bis hundert sind es fünf Abrufe
|
||||||
|
MAX_TREFFER = 100
|
||||||
|
JE_SEITE = 20
|
||||||
|
|
||||||
|
|
||||||
|
async def seiten(http, pfad, params, limit, start=1):
|
||||||
|
"""So viele Seiten, wie das Limit braucht — alle auf einmal. Was
|
||||||
|
fehlschlägt, fehlt eben; die Reihenfolge der Seiten bleibt."""
|
||||||
|
anzahl = -(-limit // JE_SEITE)
|
||||||
|
|
||||||
|
async def eine(nummer):
|
||||||
|
try:
|
||||||
|
resp = await http.get(pfad, params={**params, "page": nummer})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
antworten = await asyncio.gather(*(eine(start + i) for i in range(anzahl)))
|
||||||
|
results = [r for a in antworten for r in a.get("results", [])]
|
||||||
|
return results, antworten[0] if antworten else {}
|
||||||
|
|
||||||
|
|
||||||
async def discover(filter_, limit=40):
|
async def discover(filter_, limit=40):
|
||||||
@@ -148,22 +180,16 @@ async def discover(filter_, limit=40):
|
|||||||
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
|
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
|
||||||
|
|
||||||
async with client() as http:
|
async with client() as http:
|
||||||
resp = await http.get("/discover/movie", params=params)
|
results, erste = await seiten(http, "/discover/movie", params, limit)
|
||||||
resp.raise_for_status()
|
|
||||||
daten = resp.json()
|
|
||||||
|
|
||||||
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
|
# Bei "Zufällig" nochmal von einer beliebigen Stelle holen — sonst
|
||||||
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
|
# stehen bei jedem Aufruf dieselben Filme oben
|
||||||
if filter_.get("sort") == "random":
|
|
||||||
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
|
|
||||||
if seiten > 1:
|
|
||||||
params["page"] = random.randint(1, seiten)
|
|
||||||
resp = await http.get("/discover/movie", params=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
daten = resp.json()
|
|
||||||
|
|
||||||
results = daten.get("results", [])
|
|
||||||
if filter_.get("sort") == "random":
|
if filter_.get("sort") == "random":
|
||||||
|
gesamt = min(erste.get("total_pages") or 1, MAX_SEITE)
|
||||||
|
noetig = -(-limit // JE_SEITE)
|
||||||
|
if gesamt > noetig:
|
||||||
|
start = random.randint(1, gesamt - noetig + 1)
|
||||||
|
results, _ = await seiten(http, "/discover/movie", params, limit, start)
|
||||||
random.shuffle(results)
|
random.shuffle(results)
|
||||||
|
|
||||||
usable = [r for r in results if r.get("poster_path")]
|
usable = [r for r in results if r.get("poster_path")]
|
||||||
@@ -172,14 +198,37 @@ async def discover(filter_, limit=40):
|
|||||||
return treffer
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_zeichen(text):
|
||||||
|
"""Umlaute und Akzente auf den Grundbuchstaben zurückführen."""
|
||||||
|
zerlegt = unicodedata.normalize("NFD", text)
|
||||||
|
return "".join(z for z in zerlegt if not unicodedata.combining(z))
|
||||||
|
|
||||||
|
|
||||||
async def search(query, limit=24):
|
async def search(query, limit=24):
|
||||||
async with client() as http:
|
# TMDB findet angefangene Wörter nur ohne Umlaut: "Wilden Hüh" liefert
|
||||||
resp = await http.get(
|
# nichts, "Wilden Huh" die drei Hühner-Filme. Wer beim Tippen sucht,
|
||||||
|
# steht sonst vor einer leeren Seite. Also beide Schreibweisen fragen.
|
||||||
|
schlicht = ohne_zeichen(query)
|
||||||
|
begriffe = [query] if schlicht == query else [query, schlicht]
|
||||||
|
|
||||||
|
async def einmal(begriff):
|
||||||
|
results, _ = await seiten(
|
||||||
|
http,
|
||||||
"/search/movie",
|
"/search/movie",
|
||||||
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
|
{"query": begriff, "language": LANGUAGE, "include_adult": "false"},
|
||||||
|
limit,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
return results
|
||||||
results = resp.json().get("results", [])
|
|
||||||
|
async with client() as http:
|
||||||
|
results = []
|
||||||
|
gesehen = set()
|
||||||
|
# Die Treffer der wörtlichen Suche stehen vorn
|
||||||
|
for liste in await asyncio.gather(*(einmal(b) for b in begriffe)):
|
||||||
|
for eintrag in liste:
|
||||||
|
if eintrag["id"] not in gesehen:
|
||||||
|
gesehen.add(eintrag["id"])
|
||||||
|
results.append(eintrag)
|
||||||
|
|
||||||
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
||||||
# Dubletten oder Making-ofs
|
# Dubletten oder Making-ofs
|
||||||
@@ -313,6 +362,114 @@ async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
|
|||||||
return treffer
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
# ── Personen: Schauspieler und Regisseure ────────────────────────────────
|
||||||
|
MAX_PERSONEN = 12
|
||||||
|
# Laufzeit und Szenenbilder kosten je Film einen Abruf — bei einer langen
|
||||||
|
# Filmografie ein paar Sekunden. Einmal geholt, bleibt es einen Tag im
|
||||||
|
# Speicher; so oft ändert sich eine Filmografie nicht.
|
||||||
|
PERSONEN_CACHE = {}
|
||||||
|
PERSONEN_FRIST = 24 * 3600
|
||||||
|
|
||||||
|
FACH = {
|
||||||
|
"Acting": "Schauspiel",
|
||||||
|
"Directing": "Regie",
|
||||||
|
"Writing": "Drehbuch",
|
||||||
|
"Production": "Produktion",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def personen(query):
|
||||||
|
"""Leute suchen. Wer ein Foto hat, steht vorn — sonst die Bekannten."""
|
||||||
|
async with client() as http:
|
||||||
|
try:
|
||||||
|
resp = await http.get(
|
||||||
|
"/search/person",
|
||||||
|
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
roh = resp.json().get("results", [])
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
roh.sort(key=lambda p: (not p.get("profile_path"), -(p.get("popularity") or 0)))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": p["id"],
|
||||||
|
"name": p.get("name"),
|
||||||
|
"fach": FACH.get(p.get("known_for_department"), p.get("known_for_department") or ""),
|
||||||
|
"profile_path": p.get("profile_path"),
|
||||||
|
"bekannt_fuer": [
|
||||||
|
k.get("title") or k.get("name")
|
||||||
|
for k in p.get("known_for", [])
|
||||||
|
if k.get("title") or k.get("name")
|
||||||
|
][:3],
|
||||||
|
}
|
||||||
|
for p in roh[:MAX_PERSONEN]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def credits_aufbereiten(daten):
|
||||||
|
"""Aus den movie_credits zwei Listen: was jemand gespielt und wo er
|
||||||
|
Regie geführt hat. Ohne Poster oder doppelt fliegt raus, die
|
||||||
|
bekanntesten Filme stehen vorn. Kurzfilme und Making-ofs bleiben
|
||||||
|
drin — die sortiert die Filterleiste aus, und zwar sichtbar."""
|
||||||
|
|
||||||
|
def liste(eintraege):
|
||||||
|
raus, dabei = [], set()
|
||||||
|
for e in sorted(eintraege, key=lambda e: -(e.get("popularity") or 0)):
|
||||||
|
if not e.get("poster_path") or e["id"] in dabei:
|
||||||
|
continue
|
||||||
|
dabei.add(e["id"])
|
||||||
|
raus.append(as_result(e))
|
||||||
|
return raus
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gespielt": liste(daten.get("cast", [])),
|
||||||
|
"regie": liste([c for c in daten.get("crew", []) if c.get("job") == "Director"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Mehr Namen passen nicht in die Filmansicht, und ab da sind es Nebenrollen
|
||||||
|
MAX_CAST = 8
|
||||||
|
|
||||||
|
|
||||||
|
async def credits(movie_id):
|
||||||
|
"""Regie und die wichtigsten Darsteller — als Sprungbrett zur
|
||||||
|
Personensuche. Fehlt etwas, bleiben die Listen leer."""
|
||||||
|
async with client() as http:
|
||||||
|
try:
|
||||||
|
# Ohne Sprache: auf Deutsch kämen russische Namen in Kyrillisch
|
||||||
|
# und eingedeutschte Umschriften wie "Ilja Wiktorowitsch"
|
||||||
|
resp = await http.get(f"/movie/{movie_id}/credits", params={"language": "en-US"})
|
||||||
|
resp.raise_for_status()
|
||||||
|
daten = resp.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {"regie": [], "cast": []}
|
||||||
|
|
||||||
|
def person(p, fach):
|
||||||
|
return {"id": p["id"], "name": p.get("name"), "fach": fach, "profile_path": p.get("profile_path")}
|
||||||
|
|
||||||
|
regie, dabei = [], set()
|
||||||
|
for c in daten.get("crew", []):
|
||||||
|
if c.get("job") == "Director" and c["id"] not in dabei:
|
||||||
|
dabei.add(c["id"])
|
||||||
|
regie.append(person(c, "Regie"))
|
||||||
|
cast = sorted(daten.get("cast", []), key=lambda c: c.get("order", 999))[:MAX_CAST]
|
||||||
|
return {"regie": regie, "cast": [person(c, "Schauspiel") for c in cast]}
|
||||||
|
|
||||||
|
|
||||||
|
async def person_filme(person_id):
|
||||||
|
gemerkt = PERSONEN_CACHE.get(person_id)
|
||||||
|
if gemerkt and time.monotonic() - gemerkt[0] < PERSONEN_FRIST:
|
||||||
|
return gemerkt[1]
|
||||||
|
async with client() as http:
|
||||||
|
resp = await http.get(f"/person/{person_id}/movie_credits", params={"language": LANGUAGE})
|
||||||
|
resp.raise_for_status()
|
||||||
|
rollen = credits_aufbereiten(resp.json())
|
||||||
|
await details_nachladen(http, rollen["gespielt"] + rollen["regie"])
|
||||||
|
PERSONEN_CACHE[person_id] = (time.monotonic(), rollen)
|
||||||
|
return rollen
|
||||||
|
|
||||||
|
|
||||||
async def titel_richten(http, film):
|
async def titel_richten(http, film):
|
||||||
"""Originalschrift durch den englischen Titel ersetzen."""
|
"""Originalschrift durch den englischen Titel ersetzen."""
|
||||||
if not any(
|
if not any(
|
||||||
@@ -357,6 +514,7 @@ async def detail(movie_id):
|
|||||||
"images": pick_backdrops(data)[:12],
|
"images": pick_backdrops(data)[:12],
|
||||||
"trailer_key": pick_trailer(data),
|
"trailer_key": pick_trailer(data),
|
||||||
"imdb_id": data.get("imdb_id"),
|
"imdb_id": data.get("imdb_id"),
|
||||||
|
"rentable": pick_rentable(data),
|
||||||
"netflix_id": None,
|
"netflix_id": None,
|
||||||
"netflix_url": None,
|
"netflix_url": None,
|
||||||
"on_netflix": 0,
|
"on_netflix": 0,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Der Spendenbaum: wer schon einmal etwas überwiesen hat.
|
||||||
|
|
||||||
|
Ein Blatt je Person, ohne Betrag. Der Host trägt ein und nimmt weg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS spenden (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
with connect() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def alle():
|
||||||
|
"""Die Blätter, das neueste zuerst."""
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT s.user_id, u.name, s.created_at FROM spenden s "
|
||||||
|
"JOIN users u ON u.id = s.user_id ORDER BY s.created_at DESC, s.user_id DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def eintragen(user_id):
|
||||||
|
"""Ein zweites Mal ändert nichts — ein Blatt je Person."""
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO spenden (user_id, created_at) VALUES (?, ?)",
|
||||||
|
(user_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def entfernen(user_id):
|
||||||
|
with connect() as conn:
|
||||||
|
return conn.execute("DELETE FROM spenden WHERE user_id = ?", (user_id,)).rowcount > 0
|
||||||
+139
-26
@@ -1,4 +1,4 @@
|
|||||||
"""Filmvorschläge fürs Rad.
|
"""Filmvorschläge für die Ziehung.
|
||||||
|
|
||||||
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
|
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
|
||||||
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
|
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
|
||||||
@@ -14,12 +14,15 @@ import sqlite3
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import users
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
from .users import WEM, besitzer
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS suggestions (
|
CREATE TABLE IF NOT EXISTS suggestions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id TEXT NOT NULL UNIQUE,
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
movie_id INTEGER NOT NULL,
|
movie_id INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
@@ -29,17 +32,43 @@ CREATE TABLE IF NOT EXISTS suggestions (
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Wer am Abend da ist, aber nichts vorschlagen will. Zusammen mit den
|
||||||
|
# Vorschlagenden ergibt das die Anwesenheit — die zählt beim Dreh.
|
||||||
|
DABEI = """
|
||||||
|
CREATE TABLE IF NOT EXISTS dabei (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
COOKIE = "filmabend_sid"
|
COOKIE = "filmabend_sid"
|
||||||
|
|
||||||
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
||||||
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
|
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
|
||||||
# trotzdem bekommt.
|
# trotzdem bekommt.
|
||||||
DREH_DAUER = 4.2
|
DREH_DAUER = 11.2
|
||||||
|
|
||||||
|
# Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
|
||||||
|
# Postern rast vorbei und bremst auf dem Gewinner aus. Der Streifen ist
|
||||||
|
# so lang, und an dieser Stelle steht der Treffer.
|
||||||
|
STREIFEN_LAENGE = 64
|
||||||
|
STREIFEN_ZIEL = 56
|
||||||
|
|
||||||
|
# Manchmal bricht nach dem Halt die Halterung: die Bahn rutscht ein paar
|
||||||
|
# Poster zurück, und ein anderer gewinnt. So lange dauert das im Browser
|
||||||
|
# zusätzlich (Ruhe plus Rückfahrt — die Werte stehen auch in WheelTab.vue).
|
||||||
|
BRUCH_DAUER = 3.7
|
||||||
|
BRUCH_ANTEIL = 0.5
|
||||||
|
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA + DABEI)
|
||||||
|
# Ein Vorschlag je Person
|
||||||
|
users.je_person(conn, "suggestions", SCHEMA)
|
||||||
|
users.je_person(conn, "dabei", DABEI)
|
||||||
|
|
||||||
|
|
||||||
def neue_session():
|
def neue_session():
|
||||||
@@ -49,15 +78,27 @@ def neue_session():
|
|||||||
def alle():
|
def alle():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM suggestions ORDER BY created_at ASC"
|
# Laufzeit und Genres stehen im Katalog. Ein Vorschlag von
|
||||||
|
# außerhalb hat sie nicht — die Liste zeigt dann nur den Titel.
|
||||||
|
"SELECT s.*, m.runtime, m.genres FROM suggestions s "
|
||||||
|
"LEFT JOIN movies m ON m.id = s.movie_id "
|
||||||
|
"ORDER BY s.created_at ASC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [
|
||||||
|
{
|
||||||
|
**{k: v for k, v in dict(r).items() if k != "genres"},
|
||||||
|
"genres": json.loads(r["genres"]) if r["genres"] else [],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def von_session(session_id):
|
def von_session(session_id):
|
||||||
|
"""Der eigene Vorschlag — auch aus einem anderen Browser."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM suggestions WHERE session_id = ?", (session_id,)
|
f"SELECT * FROM suggestions WHERE {WEM}", (ich, ich, session_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
@@ -66,6 +107,7 @@ def anlegen(session_id, movie, name=None):
|
|||||||
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
|
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
|
||||||
eintrag = {
|
eintrag = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
|
"user_id": besitzer(session_id),
|
||||||
"movie_id": movie["id"],
|
"movie_id": movie["id"],
|
||||||
"title": movie["title"],
|
"title": movie["title"],
|
||||||
"year": movie.get("year"),
|
"year": movie.get("year"),
|
||||||
@@ -77,9 +119,10 @@ def anlegen(session_id, movie, name=None):
|
|||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO suggestions "
|
"INSERT INTO suggestions "
|
||||||
"(session_id, movie_id, title, year, poster_path, name, created_at) "
|
"(session_id, user_id, movie_id, title, year, poster_path, "
|
||||||
"VALUES (:session_id, :movie_id, :title, :year, :poster_path, "
|
"name, created_at) "
|
||||||
":name, :created_at)",
|
"VALUES (:session_id, :user_id, :movie_id, :title, :year, "
|
||||||
|
":poster_path, :name, :created_at)",
|
||||||
eintrag,
|
eintrag,
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
@@ -89,46 +132,116 @@ def anlegen(session_id, movie, name=None):
|
|||||||
|
|
||||||
def ersetzen(session_id, movie, name=None):
|
def ersetzen(session_id, movie, name=None):
|
||||||
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
|
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
|
||||||
with connect() as conn:
|
loeschen(session_id)
|
||||||
conn.execute("DELETE FROM suggestions WHERE session_id = ?", (session_id,))
|
|
||||||
return anlegen(session_id, movie, name)
|
return anlegen(session_id, movie, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _versatz():
|
||||||
|
"""Wo genau im Poster die Marke stehen bleibt — nie exakt mittig.
|
||||||
|
Jedes zweite Mal ganz knapp an der Kante: fast der andere."""
|
||||||
|
if random.random() < 0.5:
|
||||||
|
return round(random.choice((-1, 1)) * random.uniform(0.34, 0.46), 3)
|
||||||
|
return round(random.uniform(-0.3, 0.3), 3)
|
||||||
|
|
||||||
|
|
||||||
def ziehen():
|
def ziehen():
|
||||||
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
|
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
|
||||||
|
|
||||||
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
|
Der Streifen gehört dazu: sonst zeigt jede Seite andere Poster
|
||||||
der Zeiger stünde am Ende auf einem anderen Segment.
|
vorbeirauschen, und der Treffer stünde am Ende woanders.
|
||||||
"""
|
"""
|
||||||
eintraege = alle()
|
eintraege = alle()
|
||||||
if not eintraege:
|
if not eintraege:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
treffer = random.randrange(len(eintraege))
|
# Schon gelaufene Filme überspringen. Sind alle Vorschläge gesehen,
|
||||||
schritt = 360 / len(eintraege)
|
# bleibt es bei der vollen Auswahl — sonst gäbe es keinen Gewinner.
|
||||||
runden = 5 + random.randrange(3)
|
with connect() as conn:
|
||||||
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin
|
gesehen = {
|
||||||
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
|
r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")
|
||||||
|
}
|
||||||
|
frisch = [i for i, e in enumerate(eintraege) if e["movie_id"] not in gesehen]
|
||||||
|
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
|
||||||
|
|
||||||
|
# Blockweise gemischt: jeder Vorschlag kommt gleich oft vorbei
|
||||||
|
streifen = []
|
||||||
|
while len(streifen) < STREIFEN_LAENGE:
|
||||||
|
block = list(range(len(eintraege)))
|
||||||
|
random.shuffle(block)
|
||||||
|
streifen.extend(block)
|
||||||
|
streifen = streifen[:STREIFEN_LAENGE]
|
||||||
|
|
||||||
|
# Wo die Bahn hält — beim Bruch zweimal, und erst der zweite Halt zählt
|
||||||
|
stopps = [{"position": STREIFEN_ZIEL, "versatz": _versatz()}]
|
||||||
|
ziel = STREIFEN_ZIEL
|
||||||
|
if random.random() < BRUCH_ANTEIL:
|
||||||
|
ziel = STREIFEN_ZIEL - random.randint(1, 4)
|
||||||
|
stopps.append({"position": ziel, "versatz": _versatz()})
|
||||||
|
# Der erste Halt zeigt einen anderen — sonst wäre der Bruch egal
|
||||||
|
andere = [i for i in range(len(eintraege)) if i != treffer]
|
||||||
|
streifen[STREIFEN_ZIEL] = random.choice(andere) if andere else treffer
|
||||||
|
streifen[ziel] = treffer
|
||||||
|
|
||||||
|
def karte(e):
|
||||||
|
return {k: v for k, v in e.items() if k not in ("session_id", "user_id")}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"index": treffer,
|
"index": treffer,
|
||||||
"winkel": winkel,
|
"streifen": streifen,
|
||||||
"dauer": DREH_DAUER,
|
"stopps": stopps,
|
||||||
"gewinner": {
|
"dauer": DREH_DAUER + (BRUCH_DAUER if len(stopps) > 1 else 0),
|
||||||
k: v for k, v in eintraege[treffer].items() if k != "session_id"
|
# Der Streifen zeigt diesen Stand, auch wenn danach jemand einen
|
||||||
},
|
# Vorschlag dazutut
|
||||||
"anzahl": len(eintraege),
|
"karten": [karte(e) for e in eintraege],
|
||||||
|
"gewinner": karte(eintraege[treffer]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def loeschen(session_id):
|
def loeschen(session_id):
|
||||||
|
ich = besitzer(session_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
|
f"DELETE FROM suggestions WHERE {WEM}", (ich, ich, session_id)
|
||||||
).rowcount
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
def alles_loeschen():
|
def alles_loeschen():
|
||||||
"""Host setzt die Runde zurück."""
|
"""Host setzt die Runde zurück."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM dabei")
|
||||||
return conn.execute("DELETE FROM suggestions").rowcount
|
return conn.execute("DELETE FROM suggestions").rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def dabei_umschalten(session_id):
|
||||||
|
"""Dabei ohne Vorschlag — der zweite Klick nimmt es zurück.
|
||||||
|
Gibt zurück, ob es jetzt gesetzt ist."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
weg = conn.execute(
|
||||||
|
f"DELETE FROM dabei WHERE {WEM}", (ich, ich, session_id)
|
||||||
|
).rowcount
|
||||||
|
if weg:
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dabei (session_id, user_id, created_at) VALUES (?, ?, ?)",
|
||||||
|
(session_id, ich, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def dabei_alle():
|
||||||
|
"""Wer sich ohne Vorschlag eingetragen hat — Namen und Kennungen."""
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT d.*, u.name FROM dabei d LEFT JOIN users u ON u.id = d.user_id "
|
||||||
|
"ORDER BY d.created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def teilnehmer():
|
||||||
|
"""user_ids aller Anwesenden: Vorschlagende und Eingetragene."""
|
||||||
|
with connect() as conn:
|
||||||
|
ids = {r["user_id"] for r in conn.execute("SELECT user_id FROM suggestions")}
|
||||||
|
ids |= {r["user_id"] for r in conn.execute("SELECT user_id FROM dabei")}
|
||||||
|
return sorted(i for i in ids if i is not None)
|
||||||
+72
-2
@@ -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")
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ def build_movie(data):
|
|||||||
"images": tmdb.pick_backdrops(data),
|
"images": tmdb.pick_backdrops(data),
|
||||||
"trailer_key": tmdb.pick_trailer(data),
|
"trailer_key": tmdb.pick_trailer(data),
|
||||||
"imdb_id": data.get("imdb_id"),
|
"imdb_id": data.get("imdb_id"),
|
||||||
|
"rentable": tmdb.pick_rentable(data),
|
||||||
# Die echte Titel-ID trägt fill_netflix_links nach
|
# Die echte Titel-ID trägt fill_netflix_links nach
|
||||||
"netflix_id": None,
|
"netflix_id": None,
|
||||||
"netflix_url": tmdb.pick_watch_link(data)
|
"netflix_url": tmdb.pick_watch_link(data)
|
||||||
@@ -144,6 +145,71 @@ 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 leihbarkeit_nachtragen():
|
||||||
|
"""Filme, bei denen noch nicht steht, ob man sie leihen kann — der
|
||||||
|
Altbestand von vor dieser Spalte. Der tägliche Sync hält es danach
|
||||||
|
aktuell, hier geht es nur um das erste Mal."""
|
||||||
|
with db.connect() as conn:
|
||||||
|
offen = [r["id"] for r in conn.execute("SELECT id FROM movies WHERE rentable IS NULL")]
|
||||||
|
if not offen:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
|
||||||
|
|
||||||
|
async def hole(http, movie_id):
|
||||||
|
async with bremse:
|
||||||
|
try:
|
||||||
|
resp = await http.get(f"/movie/{movie_id}/watch/providers")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return movie_id, tmdb.pick_rentable({"watch/providers": resp.json()})
|
||||||
|
except Exception:
|
||||||
|
return movie_id, None
|
||||||
|
|
||||||
|
async with tmdb.client() as http:
|
||||||
|
stand = await asyncio.gather(*(hole(http, i) for i in offen))
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE movies SET rentable = ? WHERE id = ?",
|
||||||
|
[(wert, i) for i, wert in stand if wert is not None],
|
||||||
|
)
|
||||||
|
log.info("Leihbarkeit für %s Filme nachgetragen", sum(1 for _, w in stand if w is not None))
|
||||||
|
return len(offen)
|
||||||
|
|
||||||
|
|
||||||
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 +246,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 +265,7 @@ async def run():
|
|||||||
"netflix_links": linked,
|
"netflix_links": linked,
|
||||||
"new_teasers": new_teasers,
|
"new_teasers": new_teasers,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
|
"nachgetragen": nachgetragen,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ def pick_watch_link(data):
|
|||||||
return (providers.get(REGION) or {}).get("link")
|
return (providers.get(REGION) or {}).get("link")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_rentable(data):
|
||||||
|
"""Ob man den Film in Deutschland leihen kann — laut JustWatch, über
|
||||||
|
TMDB. None, wenn gar keine Anbieterdaten mitkamen."""
|
||||||
|
results = data.get("watch/providers", {}).get("results")
|
||||||
|
if results is None:
|
||||||
|
return None
|
||||||
|
return 1 if (results.get(REGION) or {}).get("rent") else 0
|
||||||
|
|
||||||
|
|
||||||
def pick_trailer(data):
|
def pick_trailer(data):
|
||||||
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
||||||
videos = [
|
videos = [
|
||||||
|
|||||||
+168
-6
@@ -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
|
||||||
@@ -12,6 +16,7 @@ einem Textfeld, in das jeder schreiben kann, was er will.
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import db
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@@ -26,18 +31,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 +83,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 +125,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 +144,128 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
# Der Host-Film: das Passwort für den Hostmodus, wie der Anmeldefilm
|
||||||
|
# für einen Namen. Steht in der meta-Tabelle, gesetzt aus der Verwaltung.
|
||||||
|
def host_film_setzen(movie_id):
|
||||||
|
with connect() as conn:
|
||||||
|
db.set_meta(conn, "host_film", str(movie_id) if movie_id else "")
|
||||||
|
|
||||||
|
|
||||||
|
def host_film():
|
||||||
|
wert = db.get_meta("host_film")
|
||||||
|
return int(wert) if wert 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 online_ids(session_ids):
|
||||||
|
"""Die Namen hinter offenen Verbindungen — wer gerade da ist."""
|
||||||
|
return {besitzer(sid) for sid in session_ids} - {None}
|
||||||
|
|
||||||
|
|
||||||
|
def besitzer(session_id):
|
||||||
|
"""Die user_id dieser Sitzung, oder None ohne gewählten Namen.
|
||||||
|
|
||||||
|
Sammlungen, Vorschläge und Wertungen werden damit beschriftet. Ein
|
||||||
|
Namenswechsel im selben Browser lässt die alten Einträge dann in Ruhe —
|
||||||
|
sie gehören weiter dem Namen, unter dem sie entstanden sind.
|
||||||
|
"""
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT user_id FROM user_sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row["user_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def gehoert_mir(zeile, session_id, user_id):
|
||||||
|
"""Ein Eintrag gehört mir, wenn der Name passt — sonst die Sitzung."""
|
||||||
|
if zeile["user_id"] is not None or user_id is not None:
|
||||||
|
return zeile["user_id"] == user_id
|
||||||
|
return zeile["session_id"] == session_id
|
||||||
|
|
||||||
|
|
||||||
|
# Dasselbe als WHERE-Klausel. Parameter: (user_id, user_id, session_id)
|
||||||
|
WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))"
|
||||||
|
|
||||||
|
|
||||||
|
def user_id_nachruesten(conn, tabelle):
|
||||||
|
"""Spalte ergänzen und aus der Sitzung füllen — einmalig je Datenbank."""
|
||||||
|
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
|
||||||
|
if "user_id" in spalten:
|
||||||
|
return
|
||||||
|
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE {tabelle} SET user_id = ("
|
||||||
|
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
|
||||||
|
f" {tabelle}.session_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def je_person(conn, tabelle, schema, *schluessel):
|
||||||
|
"""Eindeutig je Person statt je Sitzung.
|
||||||
|
|
||||||
|
Früher stand das UNIQUE auf der Sitzung — dann blockierte der Eintrag
|
||||||
|
des einen Namens den des nächsten, sobald beide denselben Browser
|
||||||
|
benutzten. Mit Namen zählt jetzt der Name, ohne Namen die Sitzung.
|
||||||
|
|
||||||
|
Das alte UNIQUE steckt in der Spaltendefinition und lässt sich nicht
|
||||||
|
ändern — die Tabelle wird dafür nach `schema` neu gebaut. Hat ein Name
|
||||||
|
aus mehreren Browsern doppelt eingetragen, bleibt der jüngste Eintrag.
|
||||||
|
"""
|
||||||
|
user_id_nachruesten(conn, tabelle)
|
||||||
|
alt = conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tabelle,)
|
||||||
|
).fetchone()
|
||||||
|
if alt and "UNIQUE" in alt["sql"]:
|
||||||
|
spalten = ", ".join(
|
||||||
|
r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")
|
||||||
|
)
|
||||||
|
conn.executescript(
|
||||||
|
f"ALTER TABLE {tabelle} RENAME TO {tabelle}_alt;"
|
||||||
|
f"{schema}"
|
||||||
|
f"INSERT INTO {tabelle} ({spalten}) "
|
||||||
|
f"SELECT {spalten} FROM {tabelle}_alt;"
|
||||||
|
f"DROP TABLE {tabelle}_alt;"
|
||||||
|
)
|
||||||
|
mit_name = ", ".join((*schluessel, "user_id"))
|
||||||
|
ohne_name = ", ".join((*schluessel, "session_id"))
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM {tabelle} WHERE user_id IS NOT NULL AND id NOT IN "
|
||||||
|
f"(SELECT MAX(id) FROM {tabelle} GROUP BY {mit_name})"
|
||||||
|
)
|
||||||
|
conn.executescript(
|
||||||
|
f"CREATE UNIQUE INDEX IF NOT EXISTS {tabelle}_je_benutzer "
|
||||||
|
f"ON {tabelle} ({mit_name}) WHERE user_id IS NOT NULL;"
|
||||||
|
f"CREATE UNIQUE INDEX IF NOT EXISTS {tabelle}_je_sitzung "
|
||||||
|
f"ON {tabelle} ({ohne_name}) WHERE user_id IS NULL;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def name_von(session_id):
|
def name_von(session_id):
|
||||||
|
|||||||
+302
-36
@@ -1,16 +1,21 @@
|
|||||||
"""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 .db import connect
|
from .db import connect
|
||||||
|
from .users import WEM, besitzer, gehoert_mir
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS watched (
|
CREATE TABLE IF NOT EXISTS watched (
|
||||||
@@ -22,73 +27,226 @@ CREATE TABLE IF NOT EXISTS watched (
|
|||||||
watched_at TEXT NOT NULL,
|
watched_at TEXT NOT NULL,
|
||||||
hidden INTEGER NOT NULL DEFAULT 0
|
hidden INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
RATINGS = """
|
||||||
CREATE TABLE IF NOT EXISTS ratings (
|
CREATE TABLE IF NOT EXISTS ratings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
watched_id INTEGER NOT NULL,
|
watched_id INTEGER NOT NULL,
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
stars INTEGER NOT NULL,
|
stars INTEGER NOT NULL,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
created_at TEXT NOT NULL,
|
text TEXT,
|
||||||
UNIQUE (watched_id, session_id)
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ein Herz je Person und Kommentar: "sehe ich auch so". Alle sehen die
|
||||||
|
# Zahl, wer es gegeben hat, sieht nur der Host.
|
||||||
|
HEARTS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS watched_hearts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Wer am Abend dabei war — kommt aus der Runde am Rad, sobald der Host
|
||||||
|
# den gezogenen Film einträgt. Daraus rechnen sich die Punkte.
|
||||||
|
DABEI = """
|
||||||
|
CREATE TABLE IF NOT EXISTS watched_dabei (
|
||||||
|
watched_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (watched_id, user_id)
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MAX_STERNE = 5
|
MAX_STERNE = 5
|
||||||
|
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
|
||||||
|
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)
|
conn.executescript(SCHEMA + RATINGS + NOTES + HEARTS + DABEI)
|
||||||
|
# Eine Stimme je Person und Film, ein Herz je Person und Kommentar
|
||||||
|
users.je_person(conn, "ratings", RATINGS, "watched_id")
|
||||||
|
users.je_person(conn, "watched_hearts", HEARTS, "note_id")
|
||||||
|
# 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:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
|
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
# Ein Satz zur Wertung kam später dazu
|
||||||
|
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(ratings)")}
|
||||||
|
if "text" not in spalten:
|
||||||
|
conn.execute("ALTER TABLE ratings ADD COLUMN text TEXT")
|
||||||
|
|
||||||
|
|
||||||
def _jetzt():
|
def _jetzt():
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def alle(session_id):
|
def alle(session_id, host=False):
|
||||||
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
|
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme, Kommentaren
|
||||||
|
und wer dabei war. Die laufende Nummer zählt vom ersten Abend.
|
||||||
|
|
||||||
|
Wer wie gewertet und wer ein Herz gegeben hat, sieht nur der Host —
|
||||||
|
für die Runde bleibt die Wertung anonym, das nimmt Hemmung."""
|
||||||
|
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, stars, name FROM ratings "
|
"SELECT id, watched_id, session_id, user_id, stars, name, created_at "
|
||||||
"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()
|
||||||
|
herzen = conn.execute(
|
||||||
|
"SELECT h.note_id, h.session_id, h.user_id, u.name FROM watched_hearts h "
|
||||||
|
"LEFT JOIN users u ON u.id = h.user_id ORDER BY h.created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
anwesend = conn.execute(
|
||||||
|
"SELECT d.watched_id, u.id, u.name FROM watched_dabei d "
|
||||||
|
"JOIN users u ON u.id = d.user_id ORDER BY u.name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
|
||||||
|
herz_je_notiz = {}
|
||||||
|
for h in herzen:
|
||||||
|
herz_je_notiz.setdefault(h["note_id"], []).append(h)
|
||||||
|
dabei_je_film = {}
|
||||||
|
for d in anwesend:
|
||||||
|
dabei_je_film.setdefault(d["watched_id"], []).append({"id": d["id"], "name": d["name"]})
|
||||||
|
|
||||||
|
def notiz(n):
|
||||||
|
meine_herzen = herz_je_notiz.get(n["id"], [])
|
||||||
|
return {
|
||||||
|
"id": n["id"],
|
||||||
|
"text": n["text"],
|
||||||
|
"name": n["name"],
|
||||||
|
"created_at": n["created_at"],
|
||||||
|
"mine": gehoert_mir(n, session_id, ich),
|
||||||
|
"replies": [],
|
||||||
|
"hearts": len(meine_herzen),
|
||||||
|
"hearted": any(gehoert_mir(h, session_id, ich) for h in meine_herzen),
|
||||||
|
"hearted_by": [h["name"] or "Anonym" for h in meine_herzen] if host else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
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"], "stars": s["stars"], "name": s["name"]}
|
{
|
||||||
|
"id": s["id"],
|
||||||
|
"stars": s["stars"],
|
||||||
|
"name": s["name"],
|
||||||
|
"created_at": s["created_at"],
|
||||||
|
"mine": gehoert_mir(s, session_id, ich),
|
||||||
|
}
|
||||||
for s in eigene
|
for s in eigene
|
||||||
],
|
]
|
||||||
|
if host
|
||||||
|
else [],
|
||||||
|
"dabei": dabei_je_film.get(film["id"], []),
|
||||||
|
"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": next(
|
"mine": meine["stars"] if meine else None,
|
||||||
(s["stars"] for s in eigene if s["session_id"] == session_id),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def juengster():
|
||||||
|
"""Der zuletzt geschaute Abend — für Marken ohne Dreh."""
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def von_film(movie_id):
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM watched WHERE movie_id = ?", (movie_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def markieren(movie):
|
def markieren(movie):
|
||||||
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
@@ -110,8 +268,32 @@ def markieren(movie):
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def entfernen(watched_id):
|
def dabei_setzen(watched_id, user_ids):
|
||||||
|
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO watched_dabei (watched_id, user_id) VALUES (?, ?)",
|
||||||
|
[(watched_id, i) for i in user_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _herzen_weg(conn, note_ids):
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM watched_hearts WHERE note_id = ?", [(i,) for i in note_ids]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def entfernen(watched_id):
|
||||||
|
"""Samt Wertungen, Kommentaren, Herzen und Anwesenheit."""
|
||||||
|
with connect() as conn:
|
||||||
|
notizen = [
|
||||||
|
r["id"]
|
||||||
|
for r in conn.execute("SELECT id FROM watched_notes WHERE watched_id = ?", (watched_id,))
|
||||||
|
]
|
||||||
|
_herzen_weg(conn, notizen)
|
||||||
|
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
|
||||||
|
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,)
|
||||||
@@ -138,28 +320,25 @@ def sichtbarkeit(watched_id, versteckt):
|
|||||||
|
|
||||||
|
|
||||||
def bewerten(watched_id, session_id, stars, name=None):
|
def bewerten(watched_id, session_id, stars, name=None):
|
||||||
"""Gibt None zurück, wenn der Film gar nicht in der Liste steht."""
|
"""Sterne setzen. Gibt None zurück, wenn der Film 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,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not da:
|
if not da:
|
||||||
return None
|
return None
|
||||||
# Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte
|
ich = besitzer(session_id)
|
||||||
|
# Eine Stimme je Person — die eigene alte fliegt vorher raus
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
|
||||||
|
(watched_id, ich, ich, session_id),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ratings "
|
"INSERT INTO ratings "
|
||||||
"(watched_id, session_id, stars, name, created_at) "
|
"(watched_id, session_id, user_id, stars, name, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
"ON CONFLICT (watched_id, session_id) DO UPDATE SET "
|
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
|
||||||
"stars = excluded.stars, name = excluded.name, "
|
|
||||||
"created_at = excluded.created_at",
|
|
||||||
(
|
|
||||||
watched_id,
|
|
||||||
session_id,
|
|
||||||
stars,
|
|
||||||
(name or "").strip()[:40] or None,
|
|
||||||
_jetzt(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return {"watched_id": watched_id, "stars": stars}
|
return {"watched_id": watched_id, "stars": stars}
|
||||||
|
|
||||||
@@ -172,6 +351,93 @@ 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)]
|
||||||
|
_herzen_weg(conn, alle_ids)
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
|
||||||
|
)
|
||||||
|
return len(alle_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def herz(session_id, note_id):
|
||||||
|
"""Herz setzen oder zurücknehmen. None, wenn es den Kommentar nicht
|
||||||
|
gibt, sonst ob das Herz jetzt steht."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
weg = conn.execute(
|
||||||
|
f"DELETE FROM watched_hearts WHERE note_id = ? AND {WEM}",
|
||||||
|
(note_id, ich, ich, session_id),
|
||||||
|
).rowcount
|
||||||
|
if weg:
|
||||||
|
return False
|
||||||
|
da = conn.execute("SELECT 1 FROM watched_notes WHERE id = ?", (note_id,)).fetchone()
|
||||||
|
if not da:
|
||||||
|
return None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO watched_hearts (note_id, session_id, user_id, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(note_id, session_id, ich, _jetzt()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
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
-12
@@ -8,21 +8,24 @@ Titel und Poster kommen mit in die Zeile. Ein gemerkter Film soll lesbar
|
|||||||
bleiben, auch wenn er nie im Katalog stand oder Netflix ihn abgibt.
|
bleiben, auch wenn er nie im Katalog stand oder Netflix ihn abgibt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import users
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
from .users import WEM, besitzer, gehoert_mir
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS wishlist (
|
CREATE TABLE IF NOT EXISTS wishlist (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
movie_id INTEGER NOT NULL,
|
movie_id INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
poster_path TEXT,
|
poster_path TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL
|
||||||
UNIQUE (session_id, movie_id)
|
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -30,21 +33,39 @@ CREATE TABLE IF NOT EXISTS wishlist (
|
|||||||
def init():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
# Jeder Film einmal je Person
|
||||||
|
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(
|
||||||
"SELECT w.*, u.name FROM wishlist w "
|
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
|
||||||
"LEFT JOIN user_sessions s ON s.session_id = w.session_id "
|
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
|
||||||
"LEFT JOIN users u ON u.id = s.user_id "
|
f"SELECT w.*, u.name, {felder} FROM wishlist w "
|
||||||
|
"LEFT JOIN users u ON u.id = w.user_id "
|
||||||
|
"LEFT JOIN movies m ON m.id = w.movie_id "
|
||||||
"ORDER BY w.created_at DESC"
|
"ORDER BY w.created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
ich = besitzer(session_id)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
**{k: v for k, v in dict(r).items() if k != "session_id"},
|
**{
|
||||||
"mine": r["session_id"] == session_id,
|
k: v
|
||||||
|
for k, v in dict(r).items()
|
||||||
|
if k not in ("session_id", "user_id", "genres", "images")
|
||||||
|
},
|
||||||
|
"genres": json.loads(r["genres"]) if r["genres"] else [],
|
||||||
|
"images": json.loads(r["images"]) if r["images"] else [],
|
||||||
|
"mine": gehoert_mir(r, session_id, ich),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -56,10 +77,11 @@ def merken(session_id, movie):
|
|||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO wishlist "
|
"INSERT INTO wishlist "
|
||||||
"(session_id, movie_id, title, year, poster_path, created_at) "
|
"(session_id, user_id, movie_id, title, year, poster_path, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
session_id,
|
session_id,
|
||||||
|
besitzer(session_id),
|
||||||
movie["id"],
|
movie["id"],
|
||||||
movie["title"],
|
movie["title"],
|
||||||
movie.get("year"),
|
movie.get("year"),
|
||||||
@@ -73,19 +95,22 @@ def merken(session_id, movie):
|
|||||||
|
|
||||||
|
|
||||||
def vergessen(session_id, movie_id):
|
def vergessen(session_id, movie_id):
|
||||||
|
ich = besitzer(session_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?",
|
f"DELETE FROM wishlist WHERE {WEM} AND movie_id = ?",
|
||||||
(session_id, movie_id),
|
(ich, ich, session_id, movie_id),
|
||||||
).rowcount
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
def ids(session_id):
|
def ids(session_id):
|
||||||
"""movie_ids der gemerkten Filme, für die Detailansicht."""
|
"""movie_ids der gemerkten Filme, für die Detailansicht."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
return [
|
return [
|
||||||
r["movie_id"]
|
r["movie_id"]
|
||||||
for r in conn.execute(
|
for r in conn.execute(
|
||||||
"SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,)
|
f"SELECT movie_id FROM wishlist WHERE {WEM}",
|
||||||
|
(ich, ich, session_id),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pytest==8.4.2
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Testumgebung: eigene Datenbank, kein Netz.
|
||||||
|
|
||||||
|
Die Umgebung steht, bevor app.* importiert wird — config.py liest DB_PATH
|
||||||
|
beim Import. Die Schlüssel prüft config.py nur auf Vorhandensein.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
|
||||||
|
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
||||||
|
os.environ["CLIPS_DIR"] = str(_ORDNER / "clips")
|
||||||
|
os.environ.setdefault("TMDB_TOKEN", "test")
|
||||||
|
os.environ.setdefault("MINIMAX_KEY", "test")
|
||||||
|
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
|
||||||
|
os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app import clips, db, features, main, spenden, suggestions, users, watched, wishlist # noqa: E402
|
||||||
|
|
||||||
|
# Zählt, ob der Startup doch einen Sync anstoßen wollte
|
||||||
|
_SYNC_AUFRUFE = []
|
||||||
|
|
||||||
|
|
||||||
|
async def kein_sync():
|
||||||
|
_SYNC_AUFRUFE.append(True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def app_start():
|
||||||
|
"""Startup einmal je Lauf. last_sync steht vorher, damit main.startup
|
||||||
|
keinen TMDB-Sync startet; guarded_sync ist zur Sicherheit ersetzt."""
|
||||||
|
db.init()
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.set_meta(conn, "last_sync", "test")
|
||||||
|
main.guarded_sync = kein_sync
|
||||||
|
with TestClient(main.app):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sync_aufrufe():
|
||||||
|
return _SYNC_AUFRUFE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def frische_db(app_start):
|
||||||
|
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
|
||||||
|
db.DB_PATH.unlink(missing_ok=True)
|
||||||
|
for modul in (db, users, suggestions, watched, clips, wishlist, features, spenden):
|
||||||
|
modul.init()
|
||||||
|
main.letzter_dreh.update(ergebnis=None, start=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _anmelden(client, name):
|
||||||
|
"""Namen anlegen, falls nötig, und für diesen Client wählen."""
|
||||||
|
antwort = client.post("/api/users", json={"name": name})
|
||||||
|
if antwort.status_code == 200:
|
||||||
|
user_id = antwort.json()["id"]
|
||||||
|
else:
|
||||||
|
runde = client.get("/api/users").json()["items"]
|
||||||
|
user_id = next(u["id"] for u in runde if u["name"] == name)
|
||||||
|
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
|
||||||
|
|
||||||
|
|
||||||
|
# Ein Client je Person — jeder hält sein eigenes Cookie. Beide stehen in
|
||||||
|
# der Runde, denn ohne Namen darf niemand etwas eintragen.
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
c = TestClient(main.app)
|
||||||
|
_anmelden(c, "Anna")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client2():
|
||||||
|
c = TestClient(main.app)
|
||||||
|
_anmelden(c, "Ben")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zuschauer():
|
||||||
|
"""Ohne gewählten Namen: darf alles lesen, nichts schreiben."""
|
||||||
|
return TestClient(main.app)
|
||||||
|
|
||||||
|
|
||||||
|
HOST_SCHLUESSEL = "test-host-schluessel"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hostmodus():
|
||||||
|
"""Schaltet einen Client per Hostschlüssel frei."""
|
||||||
|
|
||||||
|
def an(client):
|
||||||
|
return client.post(
|
||||||
|
"/api/host", json={"an": True, "schluessel": HOST_SCHLUESSEL}
|
||||||
|
)
|
||||||
|
|
||||||
|
return an
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def film():
|
||||||
|
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""
|
||||||
|
|
||||||
|
def anlegen(movie_id, title=None):
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.upsert(conn, {"id": movie_id, "title": title or f"Film {movie_id}"})
|
||||||
|
return movie_id
|
||||||
|
|
||||||
|
return anlegen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anmelden():
|
||||||
|
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
|
||||||
|
return _anmelden
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Clips: Marke setzen, Fenster ziehen, schneiden."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import clips, db, main, punkte
|
||||||
|
|
||||||
|
|
||||||
|
def marke(client):
|
||||||
|
antwort = client.post("/api/clips")
|
||||||
|
assert antwort.status_code == 200, antwort.text
|
||||||
|
return antwort.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_marke_ist_nur_ein_zeitpunkt(client, film):
|
||||||
|
"""Ohne Dreh und ohne Abend gehört die Marke keinem Film; das Fenster
|
||||||
|
hat Standardwerte."""
|
||||||
|
m = marke(client)
|
||||||
|
assert m["movie_id"] is None
|
||||||
|
assert (m["vorher"], m["dauer"]) == (clips.VORHER, clips.DAUER)
|
||||||
|
assert m["mine"] is True and m["geschnitten"] is False
|
||||||
|
assert m["name"] == "Anna"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marke_haengt_am_gezogenen_film(client, zuschauer, hostmodus, film):
|
||||||
|
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
zuschauer.post("/api/spin")
|
||||||
|
m = marke(client)
|
||||||
|
assert m["movie_id"] == film(1)
|
||||||
|
assert m["title"] == "Film 1"
|
||||||
|
main.letzter_dreh["ergebnis"] = None
|
||||||
|
zuschauer.delete("/api/suggestions/alle")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
|
||||||
|
client.post("/api/watched", json={"movie_id": film(1)})
|
||||||
|
client.post("/api/watched", json={"movie_id": film(2)})
|
||||||
|
assert marke(client)["movie_id"] == film(2)
|
||||||
|
|
||||||
|
|
||||||
|
def stuecke_mocken(monkeypatch):
|
||||||
|
async def segmente(von, bis):
|
||||||
|
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
|
||||||
|
return [
|
||||||
|
{"start": "2026-09-23T20:00:00Z", "duration": 30},
|
||||||
|
{"start": "2026-09-24T00:30:00Z", "duration": 30},
|
||||||
|
{"start": "2026-09-25T19:00:00Z", "duration": 30},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips, "_segmente", segmente)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufnahmen_bekommen_ihren_film(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
|
"""Zuweisung schlägt den Abend am gleichen Datum; sonst kein Film."""
|
||||||
|
stuecke_mocken(monkeypatch)
|
||||||
|
client.post("/api/watched", json={"movie_id": film(1)})
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE watched SET watched_at = '2026-09-23T20:41:00+00:00'")
|
||||||
|
|
||||||
|
liste = client.get("/api/aufnahmen").json()["stuecke"]
|
||||||
|
assert [s["start"] for s in liste] == [
|
||||||
|
"2026-09-25T19:00:00+00:00",
|
||||||
|
"2026-09-24T00:30:00+00:00",
|
||||||
|
"2026-09-23T20:00:00+00:00",
|
||||||
|
]
|
||||||
|
# Der Abend vom 23. gilt auch für die Nacht danach
|
||||||
|
assert liste[2]["film"]["id"] == film(1) and liste[2]["zugewiesen"] is False
|
||||||
|
assert liste[1]["film"]["id"] == film(1)
|
||||||
|
assert liste[0]["film"] is None
|
||||||
|
|
||||||
|
# Zuweisen darf nur der Host
|
||||||
|
zuweisung = {"start": "2026-09-25T19:00:00+00:00", "movie_id": film(2)}
|
||||||
|
assert client.put("/api/aufnahmen/film", json=zuweisung).status_code == 403
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.put("/api/aufnahmen/film", json=zuweisung).json() == {"zugewiesen": True}
|
||||||
|
liste = client.get("/api/aufnahmen").json()["stuecke"]
|
||||||
|
assert liste[0]["film"]["title"] == "Film 2" and liste[0]["zugewiesen"] is True
|
||||||
|
# Die Zuweisung schlägt den Abend
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": liste[2]["start"], "movie_id": film(2)})
|
||||||
|
assert client.get("/api/aufnahmen").json()["stuecke"][2]["film"]["id"] == film(2)
|
||||||
|
# Aufheben
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": zuweisung["start"], "movie_id": None})
|
||||||
|
assert client.get("/api/aufnahmen").json()["stuecke"][0]["film"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_aus_aufnahme(client, zuschauer, hostmodus, film):
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
start = "2026-09-23T19:00:00+00:00"
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(4)})
|
||||||
|
m = client.post("/api/clips/von-aufnahme", json={"start": start}).json()
|
||||||
|
assert m["movie_id"] == film(4) and m["title"] == "Film 4"
|
||||||
|
# Die Marke liegt kurz nach dem Anfang der Aufnahme
|
||||||
|
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
|
||||||
|
# Ohne Film gibt es die Marke trotzdem — unter Sonstige
|
||||||
|
m = client.post("/api/clips/von-aufnahme", json={"start": "2026-09-25T19:00:00+00:00"}).json()
|
||||||
|
assert m["movie_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
start = clips._iso(clips._jetzt() - timedelta(seconds=100))
|
||||||
|
|
||||||
|
async def segmente(von, bis):
|
||||||
|
return [{"start": start, "duration": 60}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips, "_segmente", segmente)
|
||||||
|
# Es läuft eine Aufnahme, aber ohne Film: dann wie bisher der neueste Abend
|
||||||
|
client.post("/api/watched", json={"movie_id": film(1)})
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE watched SET watched_at = '2020-01-01T20:00:00+00:00'")
|
||||||
|
assert marke(client)["movie_id"] == film(1)
|
||||||
|
# Zugewiesen: der Film der Aufnahme
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(3)})
|
||||||
|
assert marke(client)["movie_id"] == film(3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alle_sehen_clips_aendern_nur_besitzer_und_host(client, client2, zuschauer, hostmodus):
|
||||||
|
m = marke(client)
|
||||||
|
fremd = [c for c in client2.get("/api/clips").json() if c["id"] == m["id"]]
|
||||||
|
assert len(fremd) == 1 and fremd[0]["mine"] is False
|
||||||
|
assert client2.patch(f"/api/clips/{m['id']}", json={"titel": "x"}).status_code == 404
|
||||||
|
assert client2.delete(f"/api/clips/{m['id']}").status_code == 404
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.patch(f"/api/clips/{m['id']}", json={"titel": "Vom Host"}).json()["titel"] == "Vom Host"
|
||||||
|
assert zuschauer.get("/api/clips").json()[0]["id"] == m["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenster_wird_begrenzt_und_macht_schnitt_ungueltig(client, monkeypatch):
|
||||||
|
m = marke(client)
|
||||||
|
geaendert = client.patch(f"/api/clips/{m['id']}", json={"vorher": 99999, "dauer": 1, "titel": " Hallo "}).json()
|
||||||
|
assert (geaendert["vorher"], geaendert["dauer"], geaendert["titel"]) == (clips.MAX_VORHER, clips.MIN_DAUER, "Hallo")
|
||||||
|
# Das Fenster darf auch nach der Marke liegen — bis vier Stunden weit
|
||||||
|
assert client.patch(f"/api/clips/{m['id']}", json={"vorher": -60}).json()["vorher"] == -60
|
||||||
|
assert client.patch(f"/api/clips/{m['id']}", json={"vorher": -99999}).json()["vorher"] == -clips.MAX_VORHER
|
||||||
|
client.patch(f"/api/clips/{m['id']}", json={"vorher": clips.MAX_VORHER, "dauer": 1})
|
||||||
|
|
||||||
|
# Schneiden: der Playback-Server wird nachgestellt
|
||||||
|
class Antwort:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def aiter_bytes(self):
|
||||||
|
yield b"mp4-daten"
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stream(self, methode, url, params=None):
|
||||||
|
aufrufe.append(params)
|
||||||
|
return Antwort()
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
|
||||||
|
fertig = client.post(f"/api/clips/{m['id']}/schneiden").json()
|
||||||
|
assert fertig["geschnitten"] is True
|
||||||
|
assert aufrufe[0]["path"] == "filmabend" and aufrufe[0]["duration"] == clips.MIN_DAUER
|
||||||
|
assert (clips.CLIPS_DIR / f"{m['id']}.mp4").read_bytes() == b"mp4-daten"
|
||||||
|
video = client.get(f"/api/clips/{m['id']}/video")
|
||||||
|
assert video.status_code == 200 and video.content == b"mp4-daten"
|
||||||
|
|
||||||
|
# Neues Fenster: Datei weg, muss neu geschnitten werden
|
||||||
|
neu = client.patch(f"/api/clips/{m['id']}", json={"dauer": 20}).json()
|
||||||
|
assert neu["geschnitten"] is False
|
||||||
|
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
|
||||||
|
assert client.get(f"/api/clips/{m['id']}/video").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_schneiden_ohne_aufnahme_meldet_502(client, monkeypatch):
|
||||||
|
m = marke(client)
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stream(self, *a, **kw):
|
||||||
|
raise httpx.ConnectError("weg")
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
|
||||||
|
assert client.post(f"/api/clips/{m['id']}/schneiden").status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_nimmt_die_datei_mit(client):
|
||||||
|
m = marke(client)
|
||||||
|
clips.CLIPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(clips.CLIPS_DIR / f"{m['id']}.mp4").write_bytes(b"x")
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (f"{m['id']}.mp4", m["id"]))
|
||||||
|
assert client.delete(f"/api/clips/{m['id']}").json()["removed"] == 1
|
||||||
|
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
|
||||||
|
assert client.get("/api/clips").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschauer_darf_keine_marke_setzen(zuschauer):
|
||||||
|
assert zuschauer.post("/api/clips").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmente_eines_stuecks():
|
||||||
|
segmente = [
|
||||||
|
{"start": "2026-09-23T19:00:00.5Z"},
|
||||||
|
{"start": "2026-09-23T19:00:30.5Z"},
|
||||||
|
{"start": "2026-09-23T19:01:00.5Z"},
|
||||||
|
{"start": "2026-09-23T21:00:00Z"},
|
||||||
|
]
|
||||||
|
# Das Stück von 19:00 mit 61 s Dauer: drei Segmente, das von 21:00 nicht
|
||||||
|
assert clips._im_stueck(segmente, "2026-09-23T19:00:00+00:00", 61.0) == [
|
||||||
|
"2026-09-23T19:00:00.5Z",
|
||||||
|
"2026-09-23T19:00:30.5Z",
|
||||||
|
"2026-09-23T19:01:00.5Z",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufnahme_loescht_nur_der_host(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
|
geloescht = []
|
||||||
|
|
||||||
|
async def loeschen(start, dauer):
|
||||||
|
geloescht.append((start, dauer))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
|
||||||
|
start = "2026-09-23T19:00:00+00:00"
|
||||||
|
assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
|
||||||
|
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(1)})
|
||||||
|
antwort = zuschauer.delete("/api/aufnahmen", params={"start": start, "dauer": 60})
|
||||||
|
assert antwort.json() == {"segmente": 2}
|
||||||
|
assert geloescht == [(start, 60.0)]
|
||||||
|
# Mit der Aufnahme geht auch die Zuweisung
|
||||||
|
assert clips.film_der_aufnahme(start) is None
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Wünsche: Stimmen, Abhaken, Anmerkungen."""
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
def wunsch(client, text="Dunkles Design"):
|
||||||
|
liste = client.post("/api/features", json={"text": text}).json()
|
||||||
|
return next(w for w in liste if w["text"] == text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leerer_wunsch_wird_abgelehnt(client):
|
||||||
|
assert client.post("/api/features", json={"text": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_stimme_toggelt(client):
|
||||||
|
w = wunsch(client)
|
||||||
|
assert w["mine"] is True
|
||||||
|
assert w["votes"] == 0
|
||||||
|
assert w["voted"] is False
|
||||||
|
|
||||||
|
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": True}
|
||||||
|
[w] = client.get("/api/features").json()
|
||||||
|
assert w["votes"] == 1
|
||||||
|
assert w["voted"] is True
|
||||||
|
|
||||||
|
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": False}
|
||||||
|
[w] = client.get("/api/features").json()
|
||||||
|
assert w["votes"] == 0
|
||||||
|
assert w["voted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_erledigt_rutscht_ans_ende(client):
|
||||||
|
erster = wunsch(client, "A")
|
||||||
|
wunsch(client, "B")
|
||||||
|
# Mit Stimme steht A vorn
|
||||||
|
client.post(f"/api/features/{erster['id']}/vote")
|
||||||
|
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
|
||||||
|
|
||||||
|
antwort = client.patch(f"/api/features/{erster['id']}/done", json={"done": True})
|
||||||
|
assert antwort.json() == {"ok": True}
|
||||||
|
liste = client.get("/api/features").json()
|
||||||
|
assert [w["text"] for w in liste] == ["B", "A"]
|
||||||
|
assert liste[1]["done_at"] is not None
|
||||||
|
|
||||||
|
client.patch(f"/api/features/{erster['id']}/done", json={"done": False})
|
||||||
|
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anmerkungen_anlegen_und_loeschen(client, client2):
|
||||||
|
w = wunsch(client)
|
||||||
|
antwort = client.post(f"/api/features/{w['id']}/notes", json={"text": "Mit Kontrast"})
|
||||||
|
assert antwort.json() == {"ok": True}
|
||||||
|
|
||||||
|
[w] = client.get("/api/features").json()
|
||||||
|
[notiz] = w["notes"]
|
||||||
|
assert notiz["text"] == "Mit Kontrast"
|
||||||
|
assert notiz["mine"] is True
|
||||||
|
[fremd] = client2.get("/api/features").json()
|
||||||
|
assert fremd["notes"][0]["mine"] is False
|
||||||
|
|
||||||
|
assert client.delete(f"/api/feature-notes/{notiz['id']}").json() == {"removed": 1}
|
||||||
|
assert client.get("/api/features").json()[0]["notes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_raeumt_stimmen_und_anmerkungen_mit(client):
|
||||||
|
w = wunsch(client)
|
||||||
|
client.post(f"/api/features/{w['id']}/vote")
|
||||||
|
client.post(f"/api/features/{w['id']}/notes", json={"text": "Anmerkung"})
|
||||||
|
|
||||||
|
assert client.delete(f"/api/features/{w['id']}").json() == {"removed": 1}
|
||||||
|
assert client.get("/api/features").json() == []
|
||||||
|
with db.connect() as conn:
|
||||||
|
for tabelle in ("feature_votes", "feature_notes"):
|
||||||
|
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0
|
||||||
@@ -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})
|
||||||
|
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
|
||||||
|
|
||||||
|
|
||||||
|
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["average"] == 5.0 and f["mine"] == 5
|
||||||
|
# Die Namen an den Wertungen sieht nur der Host
|
||||||
|
assert f["ratings"] == []
|
||||||
|
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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Der Info-Reiter: ein Markdown-Text des Hosts, vorbelegt mit den Regeln."""
|
||||||
|
|
||||||
|
from app import punkte
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorlage_traegt_die_regeln_aus_dem_code(client):
|
||||||
|
text = client.get("/api/info").json()["text"]
|
||||||
|
assert text == punkte.vorlage()
|
||||||
|
assert f"- Beim Filmabend dabei: **+{punkte.SCHAUEN}**" in text
|
||||||
|
assert f"- Herz vergeben: **+{punkte.HERZ}**" in text
|
||||||
|
# Dreieckszahlen: Level 2 ab 1, Level 3 ab 3, Level 4 ab 6
|
||||||
|
assert "Level 2 ab 1, Level 3 ab 3, Level 4 ab 6" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_schreibt_nur_der_host(client, zuschauer, hostmodus):
|
||||||
|
assert client.put("/api/info", json={"text": "PayPal: x"}).status_code == 403
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.put("/api/info", json={"text": " PayPal: x "}).json()["text"] == "PayPal: x"
|
||||||
|
assert client.get("/api/info").json()["text"] == "PayPal: x"
|
||||||
|
# Leer gespeichert: wieder die Vorlage
|
||||||
|
zuschauer.put("/api/info", json={"text": ""})
|
||||||
|
assert client.get("/api/info").json()["text"] == punkte.vorlage()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""KI-Suche: Antwort des Modells lesen, Titel bei TMDB bestätigen."""
|
||||||
|
|
||||||
|
from app import ki, main
|
||||||
|
|
||||||
|
|
||||||
|
def test_parsen_zieht_json_aus_geschwaetz():
|
||||||
|
text = (
|
||||||
|
"<think>Der Nutzer mag ruhige Filme.</think>\n"
|
||||||
|
"Hier meine Vorschläge:\n"
|
||||||
|
'{"filme": [{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},'
|
||||||
|
' {"titel": "", "jahr": 1, "grund": "leer"},'
|
||||||
|
' {"titel": "Lamb", "jahr": "2021"}]}\nViel Spaß!'
|
||||||
|
)
|
||||||
|
assert ki.parsen(text) == [
|
||||||
|
{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},
|
||||||
|
{"titel": "Lamb", "jahr": 2021, "grund": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lange_begruendung_endet_am_komma():
|
||||||
|
kurz = "Wieder rätselt eine Familie um einen Mord, mit Witz."
|
||||||
|
assert ki.kuerzen(kurz) == kurz
|
||||||
|
lang = (
|
||||||
|
"Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen, "
|
||||||
|
"schräger Figuren und schlagfertiger Sprüche im Los Angeles der Siebziger."
|
||||||
|
)
|
||||||
|
gekuerzt = ki.kuerzen(lang)
|
||||||
|
assert gekuerzt == "Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen …"
|
||||||
|
assert len(gekuerzt) <= ki.MAX_GRUND + 2
|
||||||
|
# Ohne Komma bricht es am letzten Wort
|
||||||
|
ohne = "Wort " * 30
|
||||||
|
assert ki.kuerzen(ohne).endswith("Wort …") and len(ki.kuerzen(ohne)) <= ki.MAX_GRUND + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parsen_bei_muell_leer():
|
||||||
|
assert ki.parsen("Keine Ahnung.") == []
|
||||||
|
assert ki.parsen('{"filme": "nix"}') == []
|
||||||
|
assert ki.parsen("{kaputt") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_nennt_sammlung_und_gesehenes():
|
||||||
|
text = ki.prompt("blutig", ["Alien (1979)"], ["Nobody (2021)"])
|
||||||
|
assert "Gesucht: blutig" in text
|
||||||
|
assert "- Alien (1979)" in text
|
||||||
|
assert "- Nobody (2021)" in text
|
||||||
|
# Ohne Beschreibung soll die Sammlung den Ton angeben
|
||||||
|
assert "zu dieser Sammlung passt" in ki.prompt("", ["Alien (1979)"], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorgaben_aus_der_leiste():
|
||||||
|
zeilen = ki.vorgaben(
|
||||||
|
{"include": ["Horror", "Thriller"], "exclude": ["Komödie"],
|
||||||
|
"jahr_min": 2010, "note_min": 6.5, "dauer_max": 120, "stimmen_min": None}
|
||||||
|
)
|
||||||
|
assert zeilen == [
|
||||||
|
"Genre: Horror, Thriller",
|
||||||
|
"Auf keinen Fall: Komödie",
|
||||||
|
"Ab 2010",
|
||||||
|
"Mindestens 6.5 von 10 auf TMDB",
|
||||||
|
"Höchstens 120 Minuten",
|
||||||
|
]
|
||||||
|
assert ki.vorgaben({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
|
||||||
|
assert ki.vorgaben({}) == []
|
||||||
|
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
|
||||||
|
assert "Nenne genau 30 Filme" in ki.system(30)
|
||||||
|
assert "38 Originaltitel" in ki.system(30)
|
||||||
|
assert '{"merkmale"' in ki.system(12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_titel_muss_zum_treffer_passen():
|
||||||
|
assert ki._aehnlich("Clue", {"title": "Alle Mörder sind schon da", "original_title": "Clue"})
|
||||||
|
assert ki._aehnlich("The Hateful 8", {"title": "The Hateful 8", "original_title": "The Hateful Eight"})
|
||||||
|
assert ki._aehnlich("Murder by Death", {"title": "Eine Leiche zum Dessert", "original_title": "Murder by Death"})
|
||||||
|
assert not ki._aehnlich(
|
||||||
|
"The Afterparty",
|
||||||
|
{"title": "Fate: The Winx Saga - Die Afterparty", "original_title": "Fate: The Winx Saga - The Afterparty"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
|
||||||
|
aufrufe = {}
|
||||||
|
|
||||||
|
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
|
||||||
|
aufrufe.update(
|
||||||
|
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_, anzahl=anzahl
|
||||||
|
)
|
||||||
|
return [{"titel": "The Witch", "jahr": 2015, "grund": "Passt."}]
|
||||||
|
|
||||||
|
async def aufloesen(vorschlaege, filter_, ausser=()):
|
||||||
|
aufrufe["ausser"] = set(ausser)
|
||||||
|
return [{"id": 310131, "title": "The Witch", "grund": v["grund"]} for v in vorschlaege]
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.ki, "fragen", fragen)
|
||||||
|
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
|
||||||
|
|
||||||
|
# Ein Film in der Sammlung, einer gesehen
|
||||||
|
client.post("/api/wishlist", json={"movie_id": film(1, "Alien")})
|
||||||
|
client.post("/api/watched", json={"movie_id": film(2)})
|
||||||
|
|
||||||
|
antwort = client.post(
|
||||||
|
"/api/ki-suche",
|
||||||
|
json={"beschreibung": " Folk-Horror ", "mit_sammlung": True,
|
||||||
|
"include": "Horror,Mystery", "jahr_min": 2010},
|
||||||
|
)
|
||||||
|
assert antwort.status_code == 200, antwort.text
|
||||||
|
assert antwort.json()[0]["grund"] == "Passt."
|
||||||
|
assert aufrufe["beschreibung"] == "Folk-Horror"
|
||||||
|
assert aufrufe["sammlung"] == ["Alien"]
|
||||||
|
assert len(aufrufe["gesehen"]) == 1
|
||||||
|
assert aufrufe["ausser"] == {film(2)}
|
||||||
|
assert aufrufe["filter"]["include"] == ["Horror", "Mystery"]
|
||||||
|
assert aufrufe["filter"]["jahr_min"] == 2010
|
||||||
|
assert aufrufe["filter"]["note_max"] is None
|
||||||
|
assert aufrufe["anzahl"] == 12
|
||||||
|
client.post("/api/ki-suche", json={"beschreibung": "x", "limit": 40})
|
||||||
|
assert aufrufe["anzahl"] == 40
|
||||||
|
|
||||||
|
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
|
||||||
|
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
|
||||||
|
assert aufrufe["ausser"] == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nur_filter_reichen_als_frage(client, monkeypatch):
|
||||||
|
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def aufloesen(vorschlaege, filter_, ausser=()):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.ki, "fragen", fragen)
|
||||||
|
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
|
||||||
|
assert client.post("/api/ki-suche", json={"include": "Horror"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_ohne_beschreibung_und_sammlung(client):
|
||||||
|
assert client.post("/api/ki-suche", json={"beschreibung": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_wenn_die_ki_schweigt(client, monkeypatch):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def fragen(*_):
|
||||||
|
raise httpx.ConnectError("weg")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.ki, "fragen", fragen)
|
||||||
|
assert client.post("/api/ki-suche", json={"beschreibung": "x"}).status_code == 502
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Ob ein Film leihbar ist, kommt aus den Anbieterdaten von TMDB."""
|
||||||
|
|
||||||
|
from app import db, tmdb
|
||||||
|
|
||||||
|
|
||||||
|
def test_leihbar_aus_anbieterdaten():
|
||||||
|
de = lambda eintrag: {"watch/providers": {"results": {"DE": eintrag}}}
|
||||||
|
assert tmdb.pick_rentable(de({"rent": [{"provider_name": "Amazon Video"}]})) == 1
|
||||||
|
assert tmdb.pick_rentable(de({"flatrate": [{"provider_name": "Netflix"}]})) == 0
|
||||||
|
assert tmdb.pick_rentable({"watch/providers": {"results": {}}}) == 0
|
||||||
|
# Ohne Anbieterdaten bleibt es offen — nicht fälschlich "nicht leihbar"
|
||||||
|
assert tmdb.pick_rentable({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_leihbarkeit_kommt_mit_dem_film(client):
|
||||||
|
with db.connect() as conn:
|
||||||
|
db.upsert(conn, {"id": 7, "title": "Abo-Film", "rentable": 0})
|
||||||
|
db.upsert(conn, {"id": 8, "title": "Unbekannt"})
|
||||||
|
assert client.get("/api/movies/7").json()["rentable"] == 0
|
||||||
|
assert client.get("/api/movies/8").json()["rentable"] is None
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""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 hostfilm_setzen(client, hostmodus, movie_id):
|
||||||
|
"""Ein Host setzt den Host-Film und gibt die Rechte wieder ab."""
|
||||||
|
hostmodus(client)
|
||||||
|
client.post("/api/host/film", json={"movie_id": movie_id})
|
||||||
|
client.post("/api/host", json={"an": False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_hostfilm_reicht_die_tastenkombination(zuschauer):
|
||||||
|
assert zuschauer.post("/api/host", json={"an": True}).json()["host"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_hostfilm_kein_hostmodus_ohne_film(zuschauer, hostmodus, film):
|
||||||
|
hostfilm_setzen(zuschauer, hostmodus, film(1))
|
||||||
|
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, hostmodus, film):
|
||||||
|
hostfilm_setzen(zuschauer, hostmodus, film(1))
|
||||||
|
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, film):
|
||||||
|
hostfilm_setzen(zuschauer, hostmodus, film(1))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
|
||||||
|
# Nur der Host darf den Host-Film setzen oder sehen
|
||||||
|
assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403
|
||||||
|
assert client.get("/api/host/film").status_code == 403
|
||||||
|
# Ob es einen gibt, sieht jeder — welcher, nicht
|
||||||
|
assert client.get("/api/host").json() == {"host": False, "film": False}
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True
|
||||||
|
assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1)
|
||||||
|
assert client.get("/api/host").json() == {"host": False, "film": True}
|
||||||
|
|
||||||
|
# Falscher Film: nichts; richtiger Film: Host
|
||||||
|
assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403
|
||||||
|
assert client.get("/api/host").json()["host"] is False
|
||||||
|
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True
|
||||||
|
assert client.get("/api/host").json()["host"] is True
|
||||||
|
|
||||||
|
# Aufheben: ohne Host-Film reicht die Tastenkombination
|
||||||
|
zuschauer.post("/api/host/film", json={"movie_id": None})
|
||||||
|
assert zuschauer.get("/api/host/film").json()["film"] is None
|
||||||
|
client.post("/api/host", json={"an": False})
|
||||||
|
assert client.post("/api/host", json={"an": True}).json()["host"] is True
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Personensuche: Schauspieler und Regisseure, dazu ihre Filme."""
|
||||||
|
|
||||||
|
from app import main, search
|
||||||
|
|
||||||
|
|
||||||
|
def test_credits_werden_nach_rolle_getrennt():
|
||||||
|
daten = {
|
||||||
|
"cast": [
|
||||||
|
{"id": 1, "title": "Klein", "poster_path": "/a.jpg", "popularity": 2, "genre_ids": [27], "vote_count": 80},
|
||||||
|
{"id": 2, "title": "Groß", "poster_path": "/b.jpg", "popularity": 9, "release_date": "2001-05-01", "vote_count": 900},
|
||||||
|
{"id": 3, "title": "Ohne Poster", "poster_path": None, "popularity": 50, "vote_count": 500},
|
||||||
|
{"id": 2, "title": "Groß nochmal", "poster_path": "/b.jpg", "popularity": 9, "vote_count": 900},
|
||||||
|
{"id": 6, "title": "Cameo in Doku", "poster_path": "/e.jpg", "popularity": 99, "vote_count": 3},
|
||||||
|
],
|
||||||
|
"crew": [
|
||||||
|
{"id": 4, "title": "Regiearbeit", "job": "Director", "poster_path": "/c.jpg", "popularity": 1, "vote_count": 60},
|
||||||
|
{"id": 5, "title": "Nur Drehbuch", "job": "Writer", "poster_path": "/d.jpg", "popularity": 1, "vote_count": 60},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
rollen = search.credits_aufbereiten(daten)
|
||||||
|
# Auch der Cameo bleibt — was raus soll, entscheidet die Filterleiste
|
||||||
|
assert [f["title"] for f in rollen["gespielt"]] == ["Cameo in Doku", "Groß", "Klein"]
|
||||||
|
assert rollen["gespielt"][1]["year"] == 2001
|
||||||
|
assert rollen["gespielt"][2]["genres"] == ["Horror"]
|
||||||
|
assert [f["title"] for f in rollen["regie"]] == ["Regiearbeit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filmografie_kommt_aus_dem_speicher(client):
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
search.PERSONEN_CACHE[99] = (time.monotonic(), {"gespielt": [{"id": 1}], "regie": []})
|
||||||
|
# Ohne Netz — käme der Abruf durch, flöge hier ein Fehler
|
||||||
|
assert asyncio.run(search.person_filme(99))["gespielt"] == [{"id": 1}]
|
||||||
|
assert client.get("/api/personen/99/filme").json()["gespielt"] == [{"id": 1}]
|
||||||
|
del search.PERSONEN_CACHE[99]
|
||||||
|
|
||||||
|
|
||||||
|
def test_credits_route(client, monkeypatch):
|
||||||
|
async def credits(movie_id):
|
||||||
|
return {"regie": [{"id": 1, "name": "R", "fach": "Regie", "profile_path": None}], "cast": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.search, "credits", credits)
|
||||||
|
assert client.get("/api/movies/5/credits").json()["regie"][0]["fach"] == "Regie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_routen_reichen_durch(client, monkeypatch):
|
||||||
|
async def personen(query):
|
||||||
|
return [{"id": 7, "name": query, "fach": "Regie", "profile_path": None, "bekannt_fuer": []}]
|
||||||
|
|
||||||
|
async def person_filme(person_id):
|
||||||
|
return {"gespielt": [], "regie": [{"id": person_id, "title": "x"}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.search, "personen", personen)
|
||||||
|
monkeypatch.setattr(main.search, "person_filme", person_filme)
|
||||||
|
assert client.get("/api/personen?q=D").json() == []
|
||||||
|
assert client.get("/api/personen?q=Duvivier").json()[0]["name"] == "Duvivier"
|
||||||
|
assert client.get("/api/personen/7/filme").json()["regie"][0]["id"] == 7
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Erfahrung und Level: berechnet aus dem Bestand, nicht gebucht."""
|
||||||
|
|
||||||
|
from app import punkte
|
||||||
|
|
||||||
|
|
||||||
|
def abend(client, film, movie_id=1):
|
||||||
|
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def stand(client, name):
|
||||||
|
return next(u for u in client.get("/api/users").json()["items"] if u["name"] == name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_nach_dreieckszahlen():
|
||||||
|
assert punkte.level(0) == 1
|
||||||
|
assert punkte.level(1) == 2
|
||||||
|
assert punkte.level(2) == 2
|
||||||
|
assert punkte.level(3) == 3
|
||||||
|
assert punkte.level(20) == 6
|
||||||
|
assert punkte.level(21) == 7
|
||||||
|
assert punkte.level(1081) == 47
|
||||||
|
assert punkte.stand(21) == {"punkte": 21, "level": 7, "von": 21, "bis": 28}
|
||||||
|
|
||||||
|
|
||||||
|
def test_punkte_kommen_und_gehen_mit_dem_bestand(client, client2, film):
|
||||||
|
watched_id = abend(client, film)
|
||||||
|
assert stand(client, "Anna")["punkte"] == 0
|
||||||
|
|
||||||
|
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
|
||||||
|
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN
|
||||||
|
|
||||||
|
oben = client.post(f"/api/watched/{watched_id}/notes", json={"text": "gut"}).json()["id"]
|
||||||
|
client.post(f"/api/watched/{watched_id}/notes", json={"text": "nochmal"})
|
||||||
|
# Zwei Kommentare, einer zählt
|
||||||
|
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN + punkte.KOMMENTAR
|
||||||
|
|
||||||
|
client2.post(f"/api/watched/{watched_id}/notes", json={"text": "finde ich nicht", "parent_id": oben})
|
||||||
|
client2.post("/api/watched/hearts", json={"note_id": oben})
|
||||||
|
assert stand(client2, "Ben")["punkte"] == punkte.ANTWORT + punkte.HERZ
|
||||||
|
|
||||||
|
# Wertung weg, Punkte weg — nichts wird gebucht
|
||||||
|
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
|
||||||
|
assert stand(client, "Anna")["level"] == punkte.level(punkte.BEWERTEN + punkte.KOMMENTAR)
|
||||||
|
client.delete(f"/api/watched/{watched_id}")
|
||||||
|
assert stand(client, "Anna")["punkte"] == 0
|
||||||
|
assert stand(client2, "Ben")["punkte"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_anwesenheit_kommt_vom_rad(client, client2, zuschauer, hostmodus, film):
|
||||||
|
"""Wer vorschlägt oder sich einträgt, war dabei — sobald der Host den
|
||||||
|
gezogenen Film einträgt."""
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||||
|
assert client2.post("/api/dabei").json()["dabei"] is True
|
||||||
|
liste = client2.get("/api/suggestions").json()
|
||||||
|
assert liste["dabei"] == ["Ben"] and liste["dabei_mine"] is True
|
||||||
|
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.post("/api/spin").json()["started"] is True
|
||||||
|
main.letzter_dreh["start"] = 0.0 # Animation vorbei
|
||||||
|
watched_id = zuschauer.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
|
|
||||||
|
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
|
||||||
|
assert sorted(d["name"] for d in f["dabei"]) == ["Anna", "Ben"]
|
||||||
|
assert stand(client, "Anna")["punkte"] == punkte.SCHAUEN
|
||||||
|
assert stand(client, "Ben")["level"] == punkte.level(punkte.SCHAUEN)
|
||||||
|
|
||||||
|
# Der Host berichtigt: Ben kam doch nicht
|
||||||
|
anna = stand(client, "Anna")["id"]
|
||||||
|
zuschauer.post(f"/api/watched/{watched_id}/dabei", json={"user_ids": [anna]})
|
||||||
|
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
|
||||||
|
assert [d["name"] for d in f["dabei"]] == ["Anna"]
|
||||||
|
|
||||||
|
# Reset leert auch die Eingetragenen
|
||||||
|
zuschauer.delete("/api/suggestions/alle")
|
||||||
|
assert client2.get("/api/suggestions").json()["dabei"] == []
|
||||||
|
assert client2.post("/api/dabei").json()["dabei"] is True
|
||||||
|
assert client2.post("/api/dabei").json()["dabei"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_wertungen_und_herzen_sieht_nur_der_host(client, client2, zuschauer, hostmodus, film):
|
||||||
|
watched_id = abend(client, film)
|
||||||
|
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 5})
|
||||||
|
note_id = client.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).json()["id"]
|
||||||
|
client2.post("/api/watched/hearts", json={"note_id": note_id})
|
||||||
|
|
||||||
|
def film_von(c):
|
||||||
|
return next(x for x in c.get("/api/watched").json() if x["id"] == watched_id)
|
||||||
|
|
||||||
|
# Ben sieht Schnitt und Herzzahl, aber keine Namen
|
||||||
|
f = film_von(client2)
|
||||||
|
assert f["ratings"] == [] and f["average"] == 5.0 and f["count"] == 1
|
||||||
|
assert f["notes"][0]["hearts"] == 1 and f["notes"][0]["hearted"] is True
|
||||||
|
assert f["notes"][0]["hearted_by"] == []
|
||||||
|
# Anna sieht ihre eigenen Sterne trotzdem
|
||||||
|
assert film_von(client)["mine"] == 5
|
||||||
|
# Der Host sieht alles
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
f = film_von(zuschauer)
|
||||||
|
assert f["ratings"][0]["name"] == "Anna"
|
||||||
|
assert f["notes"][0]["hearted_by"] == ["Ben"]
|
||||||
|
# Herz zurück, Zähler runter; Zuschauer ohne Namen darf kein Herz geben
|
||||||
|
assert client2.post("/api/watched/hearts", json={"note_id": note_id}).json()["hearted"] is False
|
||||||
|
assert film_von(client)["notes"][0]["hearts"] == 0
|
||||||
|
assert client.post("/api/watched/hearts", json={"note_id": 999}).status_code == 404
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Der Spendenbaum: ein Blatt je Person, nur der Host pflegt ihn."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_blatt_je_person_nur_vom_host(client, zuschauer, hostmodus, anmelden):
|
||||||
|
anmelden(client, "Anna")
|
||||||
|
anna = client.get("/api/users").json()["mine"]["id"]
|
||||||
|
assert client.get("/api/spenden").json() == []
|
||||||
|
assert client.post("/api/spenden", json={"user_id": anna}).status_code == 403
|
||||||
|
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
blaetter = zuschauer.post("/api/spenden", json={"user_id": anna}).json()
|
||||||
|
assert [b["name"] for b in blaetter] == ["Anna"]
|
||||||
|
# Ein zweites Mal: immer noch ein Blatt
|
||||||
|
assert len(zuschauer.post("/api/spenden", json={"user_id": anna}).json()) == 1
|
||||||
|
assert client.get("/api/spenden").json()[0]["name"] == "Anna"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/spenden/{anna}").status_code == 403
|
||||||
|
assert zuschauer.delete(f"/api/spenden/{anna}").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_kontostand_setzt_nur_der_host(client, zuschauer, hostmodus):
|
||||||
|
assert client.get("/api/kontostand").json() == {"betrag": None}
|
||||||
|
assert client.put("/api/kontostand", json={"betrag": 12.5}).status_code == 403
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.put("/api/kontostand", json={"betrag": 12.5}).json() == {"betrag": 12.5}
|
||||||
|
assert client.get("/api/kontostand").json() == {"betrag": 12.5}
|
||||||
|
assert zuschauer.put("/api/kontostand", json={"betrag": None}).json() == {"betrag": None}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Der Startup läuft im Test ohne TMDB-Sync."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_startet_keinen_sync(client, sync_aufrufe):
|
||||||
|
assert client.get("/api/health").json() == {"ok": True}
|
||||||
|
assert sync_aufrufe == []
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Die Ziehung: Streifen, Stopps, Gewinner."""
|
||||||
|
|
||||||
|
from app import suggestions
|
||||||
|
from app.suggestions import BRUCH_DAUER, DREH_DAUER, STREIFEN_LAENGE, STREIFEN_ZIEL
|
||||||
|
|
||||||
|
|
||||||
|
def vorschlagen(client, movie_id):
|
||||||
|
antwort = client.post("/api/suggestions", json={"movie_id": movie_id})
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
return antwort.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweiter_vorschlag_ersetzt_den_ersten(client, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
neu = vorschlagen(client, film(2))
|
||||||
|
|
||||||
|
liste = client.get("/api/suggestions").json()
|
||||||
|
assert [e["movie_id"] for e in liste["items"]] == [2]
|
||||||
|
assert liste["mine"] == neu["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dreh_liefert_streifen_und_gewinner(client, client2, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
vorschlagen(client2, film(2))
|
||||||
|
|
||||||
|
ergebnis = client.post("/api/spin").json()
|
||||||
|
|
||||||
|
assert ergebnis["started"] is True
|
||||||
|
assert [k["movie_id"] for k in ergebnis["karten"]] == [1, 2]
|
||||||
|
assert len(ergebnis["streifen"]) == STREIFEN_LAENGE
|
||||||
|
assert set(ergebnis["streifen"]) <= {0, 1}
|
||||||
|
stopps = ergebnis["stopps"]
|
||||||
|
assert len(stopps) in (1, 2)
|
||||||
|
assert all(set(s) == {"position", "versatz"} for s in stopps)
|
||||||
|
assert stopps[0]["position"] == STREIFEN_ZIEL
|
||||||
|
assert ergebnis["gewinner"] == ergebnis["karten"][ergebnis["index"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ziehung_haelt_auf_dem_gewinner(client, client2, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
vorschlagen(client2, film(2))
|
||||||
|
|
||||||
|
stopp_anzahlen = set()
|
||||||
|
for _ in range(60):
|
||||||
|
e = suggestions.ziehen()
|
||||||
|
stopps = e["stopps"]
|
||||||
|
stopp_anzahlen.add(len(stopps))
|
||||||
|
# Der letzte Halt zeigt den Gewinner
|
||||||
|
assert e["streifen"][stopps[-1]["position"]] == e["index"]
|
||||||
|
assert all(-0.5 < s["versatz"] < 0.5 for s in stopps)
|
||||||
|
if len(stopps) == 2:
|
||||||
|
# Bruch: ein paar Poster zurück, der erste Halt zeigt einen anderen
|
||||||
|
assert 1 <= stopps[0]["position"] - stopps[1]["position"] <= 4
|
||||||
|
assert e["streifen"][stopps[0]["position"]] != e["index"]
|
||||||
|
assert e["dauer"] == DREH_DAUER + BRUCH_DAUER
|
||||||
|
else:
|
||||||
|
assert e["dauer"] == DREH_DAUER
|
||||||
|
# Beide Fälle kamen vor
|
||||||
|
assert stopp_anzahlen == {1, 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweiter_dreh_waehrend_laufender_ziehung(client, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
assert client.post("/api/spin").json()["started"] is True
|
||||||
|
|
||||||
|
assert client.post("/api/spin").json()["started"] is False
|
||||||
|
|
||||||
|
stand = client.get("/api/spin").json()
|
||||||
|
assert stand["running"] is True
|
||||||
|
assert stand["elapsed"] >= 0
|
||||||
|
assert stand["result"]["gewinner"]["movie_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_vorschlaege_kein_dreh(client):
|
||||||
|
assert client.post("/api/spin").status_code == 400
|
||||||
|
assert client.get("/api/spin").json() == {"running": False, "result": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gesehene_filme_gewinnen_nicht(client, client2, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
vorschlagen(client2, film(2))
|
||||||
|
client.post("/api/watched", json={"movie_id": 1})
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
assert suggestions.ziehen()["gewinner"]["movie_id"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_runde_zuruecksetzen(client, film):
|
||||||
|
vorschlagen(client, film(1))
|
||||||
|
client.post("/api/spin")
|
||||||
|
|
||||||
|
assert client.delete("/api/suggestions/alle").json() == {"removed": 1}
|
||||||
|
assert client.get("/api/spin").json()["running"] is False
|
||||||
|
assert client.get("/api/suggestions").json()["items"] == []
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Besitz: Einträge hängen am Namen, ohne Namen an der Sitzung."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_name_in_anderem_browser_sieht_eigene_eintraege(
|
||||||
|
client, client2, anmelden, film
|
||||||
|
):
|
||||||
|
anmelden(client, "Anna")
|
||||||
|
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
|
||||||
|
client.post("/api/wishlist", json={"movie_id": film(2)})
|
||||||
|
client.post("/api/features", json={"text": "Dunkles Design"})
|
||||||
|
|
||||||
|
# Anna am zweiten Gerät
|
||||||
|
anmelden(client2, "Anna")
|
||||||
|
assert client2.get("/api/suggestions").json()["mine"] == vorschlag["id"]
|
||||||
|
assert client2.get("/api/wishlist").json()[0]["mine"] is True
|
||||||
|
assert client2.get("/api/features").json()[0]["mine"] is True
|
||||||
|
|
||||||
|
# Ben nicht
|
||||||
|
anmelden(client2, "Ben")
|
||||||
|
assert client2.get("/api/suggestions").json()["mine"] is None
|
||||||
|
assert client2.get("/api/wishlist").json()[0]["mine"] is False
|
||||||
|
assert client2.get("/api/features").json()[0]["mine"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, film):
|
||||||
|
anmelden(client, "Anna")
|
||||||
|
als_anna = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
|
||||||
|
assert als_anna["name"] == "Anna"
|
||||||
|
|
||||||
|
anmelden(client, "Ben")
|
||||||
|
assert client.get("/api/suggestions").json()["mine"] is None
|
||||||
|
# Bens Vorschlag ersetzt Annas nicht — einer je Name, nicht je Browser
|
||||||
|
als_ben = client.post("/api/suggestions", json={"movie_id": film(2)}).json()
|
||||||
|
liste = client.get("/api/suggestions").json()
|
||||||
|
assert [e["name"] for e in liste["items"]] == ["Anna", "Ben"]
|
||||||
|
assert liste["mine"] == als_ben["id"]
|
||||||
|
|
||||||
|
anmelden(client, "Anna")
|
||||||
|
assert client.get("/api/suggestions").json()["mine"] == als_anna["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_abwaehlen_macht_wieder_zum_zuschauer(client, film):
|
||||||
|
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||||
|
client.post("/api/users/waehlen", json={"user_id": None})
|
||||||
|
assert client.get("/api/users").json()["mine"] is None
|
||||||
|
# Lesen geht weiter, Schreiben nicht mehr
|
||||||
|
assert client.get("/api/suggestions").json()["mine"] is None
|
||||||
|
assert client.post("/api/suggestions", json={"movie_id": film(2)}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_online_ist_wer_eine_seite_offen_hat(client, anmelden):
|
||||||
|
from app import events
|
||||||
|
|
||||||
|
anmelden(client, "Anna")
|
||||||
|
sid = client.cookies.get("filmabend_sid")
|
||||||
|
ich = client.get("/api/users").json()["mine"]["id"]
|
||||||
|
|
||||||
|
def online():
|
||||||
|
return {u["id"]: u["online"] for u in client.get("/api/users").json()["items"]}
|
||||||
|
|
||||||
|
assert online()[ich] is False
|
||||||
|
queue = events.anmelden(sid)
|
||||||
|
assert online()[ich] is True
|
||||||
|
# Namenswechsel im selben Browser: der alte Name ist offline
|
||||||
|
anmelden(client, "Ben")
|
||||||
|
assert online()[ich] is False
|
||||||
|
events.abmelden(queue)
|
||||||
|
assert True not in online().values()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Wertungen: Sterne, Text, Schnitt."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def gesehen(client, film, movie_id=1):
|
||||||
|
"""Film anlegen und als gesehen eintragen. Gibt die watched-id zurück."""
|
||||||
|
film(movie_id)
|
||||||
|
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wertung_ist_nur_sterne(client, film):
|
||||||
|
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
|
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
|
||||||
|
assert antwort.json() == {"watched_id": wid, "stars": 4}
|
||||||
|
eintrag = client.get("/api/watched").json()[0]
|
||||||
|
assert eintrag["mine"] == 4
|
||||||
|
assert eintrag["average"] == 4.0
|
||||||
|
assert eintrag["count"] == 1
|
||||||
|
# Wer wie gewertet hat, sieht nur der Host
|
||||||
|
assert eintrag["ratings"] == []
|
||||||
|
assert "mine_text" not in eintrag
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_sterne_ersetzen_die_alten(client, film):
|
||||||
|
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
|
||||||
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 2})
|
||||||
|
eintrag = client.get("/api/watched").json()[0]
|
||||||
|
assert eintrag["mine"] == 2
|
||||||
|
assert eintrag["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
|
||||||
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 5})
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE ratings SET text = 'Alte Rezension'")
|
||||||
|
watched.init()
|
||||||
|
|
||||||
|
f = client.get("/api/watched").json()[0]
|
||||||
|
assert [n["text"] for n in f["notes"]] == ["Alte Rezension"]
|
||||||
|
assert f["notes"][0]["name"] == "Anna"
|
||||||
|
assert f["notes"][0]["mine"] is True
|
||||||
|
with db.connect() as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM ratings WHERE text IS NOT NULL").fetchone()[0] == 0
|
||||||
|
# Ein zweiter Start zieht nichts doppelt um
|
||||||
|
watched.init()
|
||||||
|
assert len(client.get("/api/watched").json()[0]["notes"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_wertung_ist_nicht_meine(client, client2, film):
|
||||||
|
wid = gesehen(client, film)
|
||||||
|
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
|
||||||
|
|
||||||
|
[eintrag] = client2.get("/api/watched").json()
|
||||||
|
assert eintrag["mine"] is None
|
||||||
|
assert eintrag["count"] == 1
|
||||||
|
assert eintrag["ratings"] == []
|
||||||
|
assert eintrag["count"] == 1
|
||||||
|
|
||||||
|
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
|
||||||
|
[eintrag] = client2.get("/api/watched").json()
|
||||||
|
assert eintrag["average"] == 3.0
|
||||||
|
assert eintrag["count"] == 2
|
||||||
|
assert eintrag["mine"] == 2
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Der Zuschauer: kein Name gewählt, also nur mitlesen.
|
||||||
|
|
||||||
|
Gedacht für alle, die noch nicht in der Runde stehen. Sie sollen den
|
||||||
|
Katalog, das Rad und die Wertungen der anderen sehen, aber nichts
|
||||||
|
eintragen — sonst hinge ihr Kram allein am Cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Jede Zeile: wie die Anfrage aussieht, wenn ein Zuschauer sie stellt
|
||||||
|
SCHREIBEN = [
|
||||||
|
("post", "/api/suggestions", {"movie_id": 1}),
|
||||||
|
("delete", "/api/suggestions", None),
|
||||||
|
("post", "/api/wishlist", {"movie_id": 1}),
|
||||||
|
("delete", "/api/wishlist/1", None),
|
||||||
|
("post", "/api/features", {"text": "Dunkles Design"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("methode,pfad,daten", SCHREIBEN)
|
||||||
|
def test_zuschauer_darf_nicht_schreiben(zuschauer, film, methode, pfad, daten):
|
||||||
|
film(1)
|
||||||
|
# DELETE nimmt bei TestClient keinen json-Parameter
|
||||||
|
anfrage = getattr(zuschauer, methode)
|
||||||
|
antwort = anfrage(pfad, json=daten) if daten is not None else anfrage(pfad)
|
||||||
|
assert antwort.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschauer_darf_nicht_bewerten(zuschauer, client, film):
|
||||||
|
eintrag = client.post("/api/watched", json={"movie_id": film(1)}).json()
|
||||||
|
antwort = zuschauer.post(
|
||||||
|
f"/api/watched/{eintrag['id']}/rating", json={"stars": 5, "text": "stark"}
|
||||||
|
)
|
||||||
|
assert antwort.status_code == 403
|
||||||
|
assert zuschauer.get("/api/watched").json()[0]["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschauer_darf_nicht_abstimmen_oder_ergaenzen(zuschauer, client):
|
||||||
|
wunsch = client.post("/api/features", json={"text": "Dunkles Design"}).json()[0]
|
||||||
|
assert zuschauer.post(f"/api/features/{wunsch['id']}/vote").status_code == 403
|
||||||
|
assert (
|
||||||
|
zuschauer.post(
|
||||||
|
f"/api/features/{wunsch['id']}/notes", json={"text": "dazu"}
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
assert zuschauer.get("/api/features").json()[0]["votes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschauer_darf_alles_lesen(zuschauer, client, film):
|
||||||
|
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||||
|
client.post("/api/wishlist", json={"movie_id": film(2)})
|
||||||
|
client.post("/api/features", json={"text": "Dunkles Design"})
|
||||||
|
client.post("/api/watched", json={"movie_id": film(3)})
|
||||||
|
|
||||||
|
for pfad in ("/api/movies", "/api/suggestions", "/api/wishlist",
|
||||||
|
"/api/features", "/api/watched", "/api/users", "/api/spin"):
|
||||||
|
assert zuschauer.get(pfad).status_code == 200
|
||||||
|
|
||||||
|
# Fremdes gehört ihm nicht
|
||||||
|
assert zuschauer.get("/api/suggestions").json()["mine"] is None
|
||||||
|
assert zuschauer.get("/api/wishlist").json()[0]["mine"] is False
|
||||||
|
assert zuschauer.get("/api/features").json()[0]["mine"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_kommt_ohne_namen_an_seine_knoepfe(zuschauer, client, film):
|
||||||
|
"""Den Hostmodus kennt nur der Browser — seine Routen bleiben offen."""
|
||||||
|
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||||
|
eintrag = zuschauer.post("/api/watched", json={"movie_id": film(2)}).json()
|
||||||
|
assert zuschauer.patch(
|
||||||
|
f"/api/watched/{eintrag['id']}", json={"hidden": True}
|
||||||
|
).status_code == 200
|
||||||
|
assert zuschauer.post("/api/spin").json()["started"] is True
|
||||||
|
assert zuschauer.delete("/api/suggestions/alle").status_code == 200
|
||||||
@@ -18,9 +18,14 @@ services:
|
|||||||
- web
|
- web
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
# Geschnittene Clips überleben den Neubau des Containers
|
||||||
|
- ./clips:/data/clips
|
||||||
environment:
|
environment:
|
||||||
DB_PATH: /data/horror.db
|
DB_PATH: /data/horror.db
|
||||||
|
CLIPS_DIR: /data/clips
|
||||||
MEDIAMTX_URL: http://mediamtx:8890
|
MEDIAMTX_URL: http://mediamtx:8890
|
||||||
|
MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996
|
||||||
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
depends_on:
|
depends_on:
|
||||||
- mediamtx
|
- mediamtx
|
||||||
labels:
|
labels:
|
||||||
@@ -41,6 +46,9 @@ services:
|
|||||||
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
|
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||||
|
# Die Aufnahmen der Abende — bleiben über Neustarts erhalten. Auf dem
|
||||||
|
# Server zeigt AUFNAHMEN_DIR in .env auf die gemountete Storage Box
|
||||||
|
- ${AUFNAHMEN_DIR:-./aufnahmen}:/recordings
|
||||||
# RTMP nach außen für OBS; HLS bleibt intern und läuft über die App
|
# RTMP nach außen für OBS; HLS bleibt intern und läuft über die App
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
|
|||||||
Generated
-42
@@ -516,9 +516,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -620,9 +617,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -637,9 +631,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -654,9 +645,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -671,9 +659,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -688,9 +673,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -705,9 +687,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -722,9 +701,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -739,9 +715,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -756,9 +729,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -773,9 +743,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -790,9 +757,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -807,9 +771,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -824,9 +785,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+586
-624
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,661 @@
|
|||||||
|
<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'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
host: Boolean,
|
||||||
|
// Die Runde — der Host hakt darin ab, wer dabei war
|
||||||
|
runde: { type: Array, default: () => [] },
|
||||||
|
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 dabeiUmschalten(userId) {
|
||||||
|
const ids = props.item.dabei.map((d) => d.id)
|
||||||
|
const neu = ids.includes(userId) ? ids.filter((i) => i !== userId) : [...ids, userId]
|
||||||
|
await fetch(`/api/watched/${props.item.id}/dabei`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_ids: neu }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<!-- Eine Zeile: Schnitt, Stimmenzahl in Klammern, dann die
|
||||||
|
eigenen Sterne zum Klicken -->
|
||||||
|
<p class="urteil">
|
||||||
|
<template v-if="item.count">
|
||||||
|
<span class="wert">{{ item.average.toFixed(1) }}</span>
|
||||||
|
<span class="von" :title="`${item.count} ${item.count === 1 ? 'Stimme' : 'Stimmen'}`">
|
||||||
|
({{ item.count }})
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="!angemeldet" class="von">Noch keine Wertung</span>
|
||||||
|
<span v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
|
||||||
|
<button
|
||||||
|
v-for="s in STERNE"
|
||||||
|
:key="s"
|
||||||
|
type="button"
|
||||||
|
class="stern"
|
||||||
|
:class="{ an: gefuellt(s) }"
|
||||||
|
:aria-label="`${s} von 5`"
|
||||||
|
:title="item.mine ? 'Deine Wertung' : 'Bewerten'"
|
||||||
|
@mouseenter="schwebt = s"
|
||||||
|
@click="bewerten(s)"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||||
|
|
||||||
|
<!-- Nur in der Hostansicht: in der Gastansicht bleibt die Wertung
|
||||||
|
anonym, auch wenn die Sitzung freigeschaltet ist -->
|
||||||
|
<ul v-if="host && 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 }"><PersonName :name="r.name" /></span>
|
||||||
|
<button
|
||||||
|
v-if="host"
|
||||||
|
type="button"
|
||||||
|
class="weg"
|
||||||
|
title="Wertung entfernen"
|
||||||
|
@click="wertungEntfernen(r)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Wer dabei war. Der Host tippt Namen an und aus, alle anderen
|
||||||
|
sehen nur die Liste. -->
|
||||||
|
<div v-if="host || item.dabei.length" class="dabei">
|
||||||
|
<template v-if="host">
|
||||||
|
<button
|
||||||
|
v-for="u in runde"
|
||||||
|
:key="u.id"
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
:class="{ an: item.dabei.some((d) => d.id === u.id) }"
|
||||||
|
@click="dabeiUmschalten(u.id)"
|
||||||
|
>
|
||||||
|
<PersonName :name="u.name" :level="u.level" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="dabei-namen">
|
||||||
|
<PersonName v-for="d in item.dabei" :key="d.id" :name="d.name" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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.4rem;
|
||||||
|
align-items: center;
|
||||||
|
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: inline-flex;
|
||||||
|
gap: 0.05rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stern {
|
||||||
|
padding: 0 0.05rem;
|
||||||
|
color: var(--line);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei-namen {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.7rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.an {
|
||||||
|
color: var(--text);
|
||||||
|
border-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeilen mit Trennlinie, wie bei den Wünschen */
|
||||||
|
.liste {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import MovieCard from './MovieCard.vue'
|
import MovieCard from './MovieCard.vue'
|
||||||
import { filterParameter } from '../filter.js'
|
import { filterParameter } from '../filter.js'
|
||||||
|
|
||||||
// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig
|
// Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel.
|
||||||
const TREFFER = 20
|
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
|
||||||
|
const VORSCHLAEGE = { kompakt: 20, details: 8 }
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
filter: { type: Object, default: () => ({}) },
|
filter: { type: Object, default: () => ({}) },
|
||||||
ansicht: { type: String, default: 'kompakt' },
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
ohneGesehene: { type: Boolean, default: true },
|
||||||
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
|
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
|
||||||
start: { type: Object, default: null },
|
start: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
@@ -16,7 +19,9 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['open', 'gestartet'])
|
const emit = defineEmits(['open', 'gestartet'])
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const vorschlaege = ref([])
|
const gefunden = ref([])
|
||||||
|
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
|
||||||
|
const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8))
|
||||||
const gewaehlt = ref(null)
|
const gewaehlt = ref(null)
|
||||||
const treffer = ref([])
|
const treffer = ref([])
|
||||||
const laden = ref(false)
|
const laden = ref(false)
|
||||||
@@ -32,7 +37,7 @@ let laufendeTreffer = 0
|
|||||||
// dazu passt.
|
// dazu passt.
|
||||||
async function suchen(term) {
|
async function suchen(term) {
|
||||||
if (term.trim().length < 2) {
|
if (term.trim().length < 2) {
|
||||||
vorschlaege.value = []
|
gefunden.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const meine = ++laufende
|
const meine = ++laufende
|
||||||
@@ -41,7 +46,7 @@ async function suchen(term) {
|
|||||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
const daten = await resp.json()
|
const daten = await resp.json()
|
||||||
if (meine !== laufende) return
|
if (meine !== laufende) return
|
||||||
vorschlaege.value = daten.slice(0, 8)
|
gefunden.value = daten
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (meine === laufende) fehler.value = err.message
|
if (meine === laufende) fehler.value = err.message
|
||||||
}
|
}
|
||||||
@@ -50,7 +55,7 @@ async function suchen(term) {
|
|||||||
async function waehlen(film) {
|
async function waehlen(film) {
|
||||||
const meine = ++laufendeWahl
|
const meine = ++laufendeWahl
|
||||||
gewaehlt.value = film
|
gewaehlt.value = film
|
||||||
vorschlaege.value = []
|
gefunden.value = []
|
||||||
query.value = ''
|
query.value = ''
|
||||||
nachladen()
|
nachladen()
|
||||||
|
|
||||||
@@ -76,8 +81,8 @@ async function nachladen() {
|
|||||||
laden.value = true
|
laden.value = true
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
try {
|
try {
|
||||||
|
// Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit'
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: TREFFER,
|
|
||||||
voll: 'true',
|
voll: 'true',
|
||||||
...filterParameter(props.filter),
|
...filterParameter(props.filter),
|
||||||
})
|
})
|
||||||
@@ -97,6 +102,7 @@ async function nachladen() {
|
|||||||
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
|
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
|
||||||
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
|
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
|
||||||
function passt(movie) {
|
function passt(movie) {
|
||||||
|
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
|
||||||
const werte = props.filter.dauer
|
const werte = props.filter.dauer
|
||||||
const grenze = props.filter.grenzen?.runtime
|
const grenze = props.filter.grenzen?.runtime
|
||||||
if (!werte || !grenze || movie.runtime == null) return true
|
if (!werte || !grenze || movie.runtime == null) return true
|
||||||
@@ -112,7 +118,7 @@ watch(
|
|||||||
waehlen(film)
|
waehlen(film)
|
||||||
emit('gestartet')
|
emit('gestartet')
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
||||||
@@ -123,7 +129,7 @@ watch(
|
|||||||
clearTimeout(filterTimer)
|
clearTimeout(filterTimer)
|
||||||
filterTimer = setTimeout(nachladen, 350)
|
filterTimer = setTimeout(nachladen, 350)
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(query, (term) => {
|
watch(query, (term) => {
|
||||||
@@ -149,13 +155,7 @@ async function oeffnen(movie) {
|
|||||||
placeholder="Von welchem Film ausgehen?"
|
placeholder="Von welchem Film ausgehen?"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
|
||||||
v-if="query"
|
|
||||||
type="button"
|
|
||||||
class="leeren"
|
|
||||||
title="Suche leeren"
|
|
||||||
@click="query = ''"
|
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,6 +169,7 @@ async function oeffnen(movie) {
|
|||||||
:key="film.id"
|
:key="film.id"
|
||||||
:movie="film"
|
:movie="film"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
|
:gesehen="gesehenIds.has(film.id)"
|
||||||
@open="waehlen"
|
@open="waehlen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,25 +185,17 @@ async function oeffnen(movie) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!laden" class="grid" :class="ansicht">
|
<div v-if="!laden" class="grid" :class="ansicht">
|
||||||
<MovieCard
|
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
|
||||||
:movie="gewaehlt"
|
|
||||||
:ansicht="ansicht"
|
|
||||||
class="ausgangsfilm"
|
|
||||||
@open="oeffnen"
|
|
||||||
/>
|
|
||||||
<MovieCard
|
<MovieCard
|
||||||
v-for="film in treffer.filter(passt)"
|
v-for="film in treffer.filter(passt)"
|
||||||
:key="film.id"
|
:key="film.id"
|
||||||
:movie="film"
|
:movie="film"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
|
:gesehen="gesehenIds.has(film.id)"
|
||||||
@open="oeffnen"
|
@open="oeffnen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-else class="note">
|
|
||||||
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -268,10 +261,19 @@ async function oeffnen(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
}
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.aehnlich-tab > .note {
|
.aehnlich-tab > .note {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
// Das Vorschaubild eines geschnittenen Clips: der erste Frame des Videos.
|
||||||
|
// Ein Video-Element im Hintergrund lädt den Anfang, der Frame wandert
|
||||||
|
// auf ein Canvas und von dort als Bild in die Karte. So gibt es keine
|
||||||
|
// Player-Steuerung, keine Bild-in-Bild-Hinweise und kein schwarzes Feld.
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const bild = ref(null)
|
||||||
|
let video = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
video = document.createElement('video')
|
||||||
|
video.muted = true
|
||||||
|
video.playsInline = true
|
||||||
|
video.preload = 'auto'
|
||||||
|
video.addEventListener('loadeddata', () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0)
|
||||||
|
try {
|
||||||
|
bild.value = canvas.toDataURL('image/jpeg', 0.82)
|
||||||
|
} catch {
|
||||||
|
bild.value = null
|
||||||
|
}
|
||||||
|
// Das Video wird nicht mehr gebraucht
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
})
|
||||||
|
video.src = props.src
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (video) {
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img v-if="bild" :src="bild" alt="" />
|
||||||
|
<span v-else class="leer"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img,
|
||||||
|
.leer {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<script setup>
|
||||||
|
// Ein Clip im Großen: Vorschau, der Zeitstrahl zum Zuschneiden, Titel,
|
||||||
|
// Schneiden. Liegt als Fenster über der Seite.
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import ClipTrimmer from './ClipTrimmer.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
clip: { type: Object, required: true },
|
||||||
|
// Besitzer und Host dürfen schneiden, ändern und löschen — alle
|
||||||
|
// anderen schauen nur
|
||||||
|
darf: Boolean,
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['zu', 'geaendert'])
|
||||||
|
|
||||||
|
const vorher = ref(props.clip.vorher)
|
||||||
|
const dauer = ref(props.clip.dauer)
|
||||||
|
const titel = ref(props.clip.titel || '')
|
||||||
|
const segmente = ref([])
|
||||||
|
const vorschauUrl = ref('')
|
||||||
|
const schneidet = ref(false)
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
function vorschauLaden() {
|
||||||
|
vorschauUrl.value = `/api/clips/${props.clip.id}/vorschau?vorher=${vorher.value}&dauer=${dauer.value}&t=${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(felder) {
|
||||||
|
const resp = await fetch(`/api/clips/${props.clip.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(felder),
|
||||||
|
})
|
||||||
|
if (!resp.ok) fehler.value = 'Das hat nicht geklappt.'
|
||||||
|
else emit('geaendert')
|
||||||
|
return resp.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schneiden() {
|
||||||
|
schneidet.value = true
|
||||||
|
fehler.value = ''
|
||||||
|
await patch({ vorher: vorher.value, dauer: dauer.value, titel: titel.value })
|
||||||
|
const resp = await fetch(`/api/clips/${props.clip.id}/schneiden`, { method: 'POST' })
|
||||||
|
if (!resp.ok) fehler.value = 'Die Aufnahme liefert diesen Ausschnitt nicht.'
|
||||||
|
else emit('geaendert')
|
||||||
|
schneidet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen() {
|
||||||
|
await fetch(`/api/clips/${props.clip.id}`, { method: 'DELETE' })
|
||||||
|
emit('geaendert')
|
||||||
|
emit('zu')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(e) {
|
||||||
|
if (e.key === 'Escape') emit('zu')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', escape)
|
||||||
|
vorschauLaden()
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/clips/${props.clip.id}/aufnahme`)
|
||||||
|
if (resp.ok) segmente.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann ohne Aufnahmebalken */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', escape))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="editor-overlay" @click.self="emit('zu')">
|
||||||
|
<div class="editor">
|
||||||
|
<header>
|
||||||
|
<input
|
||||||
|
v-if="darf"
|
||||||
|
v-model="titel"
|
||||||
|
type="text"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="Titel des Clips"
|
||||||
|
class="titel"
|
||||||
|
@change="patch({ titel })"
|
||||||
|
/>
|
||||||
|
<h2 v-else class="titel">{{ clip.titel || 'Ohne Titel' }}</h2>
|
||||||
|
<button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Wer nur schaut, bekommt bei geschnittenen Clips das fertige Video -->
|
||||||
|
<video
|
||||||
|
:src="!darf && clip.geschnitten ? `/api/clips/${clip.id}/video` : vorschauUrl"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
class="vorschau"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<ClipTrimmer
|
||||||
|
v-if="darf"
|
||||||
|
v-model:vorher="vorher"
|
||||||
|
v-model:dauer="dauer"
|
||||||
|
:segmente="segmente"
|
||||||
|
:marke="clip.marke_at"
|
||||||
|
@ende="vorschauLaden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||||
|
|
||||||
|
<div class="knoepfe">
|
||||||
|
<button v-if="darf" type="button" class="haupt" :disabled="schneidet" @click="schneiden">
|
||||||
|
{{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
|
||||||
|
</button>
|
||||||
|
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
|
||||||
|
>Herunterladen</a
|
||||||
|
>
|
||||||
|
<span class="rest"></span>
|
||||||
|
<button v-if="darf" type="button" class="weg" @click="loeschen">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
width: min(100%, 960px);
|
||||||
|
max-height: 95vh;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zu {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zu:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschau {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fehler {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knoepfe {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knoepfe button,
|
||||||
|
.knoepfe .link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knoepfe button:hover,
|
||||||
|
.knoepfe .link:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knoepfe .haupt {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knoepfe .haupt:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weg {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup>
|
||||||
|
// Der Zeitstrahl der Aufnahme rund um die Marke. Ein Balken zeigt das
|
||||||
|
// Fenster des Clips; an seinen Enden zieht man Anfang und Ende, in der
|
||||||
|
// Mitte schiebt man das ganze Fenster. Daneben zieht man den Strahl
|
||||||
|
// selbst nach links und rechts. Zeiten in Sekunden relativ zur Marke:
|
||||||
|
// 0 ist der Moment, an dem C gedrückt wurde.
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
|
||||||
|
segmente: { type: Array, default: () => [] },
|
||||||
|
maxVorher: { type: Number, default: 4 * 3600 },
|
||||||
|
minDauer: { type: Number, default: 3 },
|
||||||
|
maxDauer: { type: Number, default: 180 },
|
||||||
|
// Uhrzeit der Marke, für die Beschriftung
|
||||||
|
marke: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const vorher = defineModel('vorher', { type: Number, default: 30 })
|
||||||
|
const dauer = defineModel('dauer', { type: Number, default: 35 })
|
||||||
|
const emit = defineEmits(['ende'])
|
||||||
|
|
||||||
|
// Der sichtbare Ausschnitt des Strahls, zoombar
|
||||||
|
const ZOOMS = [120, 300, 900, 1800, 7200, 14400]
|
||||||
|
const zoom = ref(1)
|
||||||
|
const mitte = ref(0)
|
||||||
|
const spanne = computed(() => ZOOMS[zoom.value])
|
||||||
|
const links = computed(() => mitte.value - spanne.value / 2)
|
||||||
|
const rechts = computed(() => mitte.value + spanne.value / 2)
|
||||||
|
|
||||||
|
const strahl = ref(null)
|
||||||
|
|
||||||
|
function prozent(t) {
|
||||||
|
return ((t - links.value) / spanne.value) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = computed(() => -vorher.value)
|
||||||
|
const ende = computed(() => -vorher.value + dauer.value)
|
||||||
|
|
||||||
|
// Wo die Aufnahme überhaupt Material hat — so weit lässt sich der Strahl schieben
|
||||||
|
const grenzen = computed(() => {
|
||||||
|
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
|
||||||
|
return {
|
||||||
|
von: Math.min(...props.segmente.map((s) => s.von)),
|
||||||
|
bis: Math.max(...props.segmente.map((s) => s.bis)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Das Stück der Aufnahme, in dem das Fenster liegt. Zwischen zwei
|
||||||
|
// Stücken ist Loch — dort gibt es kein Bild, dorthin darf das Fenster
|
||||||
|
// nicht. Liegt es in einem Loch, gilt das nähere Stück.
|
||||||
|
function abstand(s, t) {
|
||||||
|
return t < s.von ? s.von - t : t > s.bis ? t - s.bis : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function stueckUm(t) {
|
||||||
|
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
|
||||||
|
const s = props.segmente.reduce((a, b) => (abstand(a, t) <= abstand(b, t) ? a : b))
|
||||||
|
return { von: Math.max(s.von, -props.maxVorher), bis: Math.min(s.bis, props.maxVorher) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomen(schritt) {
|
||||||
|
zoom.value = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + schritt))
|
||||||
|
mitte.value = (start.value + ende.value) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beschriftung: runde Schritte, etwa sechs bis acht Striche
|
||||||
|
const striche = computed(() => {
|
||||||
|
const schritt = [10, 30, 60, 300, 900, 1800][zoom.value]
|
||||||
|
const aus = []
|
||||||
|
for (let t = Math.ceil(links.value / schritt) * schritt; t <= rechts.value; t += schritt) {
|
||||||
|
aus.push({ t, text: uhrzeit(t) })
|
||||||
|
}
|
||||||
|
return aus
|
||||||
|
})
|
||||||
|
|
||||||
|
function uhrzeit(t) {
|
||||||
|
if (!props.marke) return `${t >= 0 ? '+' : '−'}${Math.abs(t)} s`
|
||||||
|
const d = new Date(new Date(props.marke).getTime() + t * 1000)
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ziehen ────────────────────────────────────────────────────────────
|
||||||
|
let griff = null
|
||||||
|
let ausgang = null
|
||||||
|
// Ob sich die Hand seit dem Anfassen bewegt hat — sonst war es ein Klick
|
||||||
|
let bewegt = false
|
||||||
|
|
||||||
|
function zeitBei(clientX) {
|
||||||
|
const box = strahl.value.getBoundingClientRect()
|
||||||
|
return links.value + ((clientX - box.left) / box.width) * spanne.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Fenster an eine Stelle setzen: sein Anfang liegt dort, es bleibt im Stück
|
||||||
|
function setzen(t) {
|
||||||
|
const g = stueckUm(t)
|
||||||
|
const d = Math.max(props.minDauer, Math.min(dauer.value, g.bis - g.von))
|
||||||
|
const s = Math.max(g.von, Math.min(g.bis - d, t))
|
||||||
|
vorher.value = Math.round(-s)
|
||||||
|
dauer.value = Math.round(d)
|
||||||
|
emit('ende')
|
||||||
|
}
|
||||||
|
|
||||||
|
function anfassen(e, was) {
|
||||||
|
griff = was
|
||||||
|
bewegt = false
|
||||||
|
ausgang = {
|
||||||
|
x: e.clientX,
|
||||||
|
vorher: vorher.value,
|
||||||
|
dauer: dauer.value,
|
||||||
|
mitte: mitte.value,
|
||||||
|
// Das Stück, in dem das Fenster beim Anfassen liegt — darin bleibt es
|
||||||
|
stueck: stueckUm(-vorher.value + dauer.value / 2),
|
||||||
|
}
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ziehen(e) {
|
||||||
|
if (!griff || !strahl.value) return
|
||||||
|
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
|
||||||
|
if (Math.abs(e.clientX - ausgang.x) > 4) bewegt = true
|
||||||
|
const g = ausgang.stueck
|
||||||
|
if (griff === 'bahn') {
|
||||||
|
// Den Strahl selbst schieben — die Zeiten laufen der Hand entgegen
|
||||||
|
const r = grenzen.value
|
||||||
|
mitte.value = Math.max(r.von, Math.min(r.bis, ausgang.mitte - dt))
|
||||||
|
} else if (griff === 'links') {
|
||||||
|
// Der Anfang wandert, das Ende bleibt stehen
|
||||||
|
const altesEnde = -ausgang.vorher + ausgang.dauer
|
||||||
|
let neuerStart = -ausgang.vorher + dt
|
||||||
|
neuerStart = Math.max(g.von, neuerStart)
|
||||||
|
neuerStart = Math.min(
|
||||||
|
altesEnde - props.minDauer,
|
||||||
|
Math.max(altesEnde - props.maxDauer, neuerStart),
|
||||||
|
)
|
||||||
|
vorher.value = Math.round(-neuerStart)
|
||||||
|
dauer.value = Math.round(altesEnde - neuerStart)
|
||||||
|
} else if (griff === 'rechts') {
|
||||||
|
let neuesEnde = -ausgang.vorher + ausgang.dauer + dt
|
||||||
|
neuesEnde = Math.min(g.bis, neuesEnde)
|
||||||
|
const d = Math.max(props.minDauer, Math.min(props.maxDauer, neuesEnde + ausgang.vorher))
|
||||||
|
dauer.value = Math.round(d)
|
||||||
|
} else {
|
||||||
|
// Das ganze Fenster verschieben, aber nicht aus dem Stück hinaus
|
||||||
|
let neuerStart = -ausgang.vorher + dt
|
||||||
|
neuerStart = Math.max(g.von, Math.min(g.bis - ausgang.dauer, neuerStart))
|
||||||
|
vorher.value = Math.round(-neuerStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loslassen(e) {
|
||||||
|
if (!griff) return
|
||||||
|
const war = griff
|
||||||
|
griff = null
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
||||||
|
if (war === 'bahn') {
|
||||||
|
// Ein Klick ohne Ziehen setzt das Fenster hierhin; Schieben braucht keine neue Vorschau
|
||||||
|
if (!bewegt) setzen(zeitBei(e.clientX))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('ende')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mausrad: zoomen, der Punkt unter dem Zeiger bleibt stehen
|
||||||
|
function rad(e) {
|
||||||
|
const neu = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + (e.deltaY > 0 ? 1 : -1)))
|
||||||
|
if (neu === zoom.value || !strahl.value) return
|
||||||
|
const box = strahl.value.getBoundingClientRect()
|
||||||
|
const anteil = (e.clientX - box.left) / box.width
|
||||||
|
const t = links.value + anteil * spanne.value
|
||||||
|
zoom.value = neu
|
||||||
|
const r = grenzen.value
|
||||||
|
mitte.value = Math.max(r.von, Math.min(r.bis, t - (anteil - 0.5) * ZOOMS[neu]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pfeiltasten: 1 s, mit Shift 10 s. Die Vorschau lädt erst, wenn Ruhe ist
|
||||||
|
let tastenTimer = null
|
||||||
|
|
||||||
|
function taste(e) {
|
||||||
|
if (e.target instanceof Element && e.target.matches('input, textarea')) return
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||||
|
e.preventDefault()
|
||||||
|
const schritt = (e.shiftKey ? 10 : 1) * (e.key === 'ArrowLeft' ? -1 : 1)
|
||||||
|
const g = stueckUm(start.value + dauer.value / 2)
|
||||||
|
const s = Math.max(g.von, Math.min(g.bis - dauer.value, start.value + schritt))
|
||||||
|
vorher.value = Math.round(-s)
|
||||||
|
clearTimeout(tastenTimer)
|
||||||
|
tastenTimer = setTimeout(() => emit('ende'), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', taste))
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', taste)
|
||||||
|
clearTimeout(tastenTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="trimmer">
|
||||||
|
<div class="kopf">
|
||||||
|
<span class="fenster">
|
||||||
|
{{ uhrzeit(start) }} – {{ uhrzeit(ende) }} · <b>{{ dauer }} s</b>
|
||||||
|
</span>
|
||||||
|
<span class="zoom">
|
||||||
|
<button type="button" :disabled="zoom === 0" title="Näher" @click="zoomen(-1)">+</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="zoom === ZOOMS.length - 1"
|
||||||
|
title="Weiter"
|
||||||
|
@click="zoomen(1)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="strahl"
|
||||||
|
class="strahl"
|
||||||
|
@pointerdown="anfassen($event, 'bahn')"
|
||||||
|
@wheel.prevent="rad"
|
||||||
|
@pointermove="ziehen"
|
||||||
|
@pointerup="loslassen"
|
||||||
|
@pointercancel="loslassen"
|
||||||
|
>
|
||||||
|
<!-- Wo Aufnahme liegt -->
|
||||||
|
<span
|
||||||
|
v-for="(s, i) in segmente"
|
||||||
|
:key="i"
|
||||||
|
class="aufnahme"
|
||||||
|
:style="{ left: prozent(s.von) + '%', width: prozent(s.bis) - prozent(s.von) + '%' }"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Die Marke: der Moment, an dem C gedrückt wurde -->
|
||||||
|
<span class="marke" :style="{ left: prozent(0) + '%' }" title="Marke"></span>
|
||||||
|
|
||||||
|
<!-- Das Fenster des Clips mit seinen Griffen -->
|
||||||
|
<span
|
||||||
|
class="bereich"
|
||||||
|
:style="{ left: prozent(start) + '%', width: prozent(ende) - prozent(start) + '%' }"
|
||||||
|
@pointerdown.stop="anfassen($event, 'mitte')"
|
||||||
|
>
|
||||||
|
<span class="griff links" @pointerdown.stop="anfassen($event, 'links')"></span>
|
||||||
|
<span class="griff rechts" @pointerdown.stop="anfassen($event, 'rechts')"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-for="s in striche" :key="s.t" class="strich" :style="{ left: prozent(s.t) + '%' }">
|
||||||
|
<span class="text">{{ s.text }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trimmer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fenster b {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom button {
|
||||||
|
width: 1.7rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strahl {
|
||||||
|
position: relative;
|
||||||
|
height: 3.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahme {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
margin-left: -1px;
|
||||||
|
background: #f5c518;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bereich {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
/* Bei vier Stunden Ansicht wäre das Fenster ein Strich — so bleibt es greifbar */
|
||||||
|
min-width: 2.2rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
background: rgba(229, 9, 20, 0.35);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.griff {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 0.7rem;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.griff.links {
|
||||||
|
left: -2px;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.griff.rechts {
|
||||||
|
right: -2px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strich {
|
||||||
|
position: absolute;
|
||||||
|
top: 2.2rem;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.3rem;
|
||||||
|
left: 0.25rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<script setup>
|
||||||
|
// Die eigenen Marken und Clips als Karten, nach Film gruppiert. Ein Klick
|
||||||
|
// öffnet den Editor mit Zeitstrahl. Der Host sieht die Clips aller.
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { abonnieren } from '../live'
|
||||||
|
import { backdropUrl, posterUrl } from '../images'
|
||||||
|
import ClipEditor from './ClipEditor.vue'
|
||||||
|
import ClipBild from './ClipBild.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
angemeldet: Boolean,
|
||||||
|
host: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
// "Clip erstellen": eine Aufnahme wählen, dann öffnet der Editor. Jede
|
||||||
|
// Aufnahme bringt ihren Film mit — oder keinen, dann läuft der Clip
|
||||||
|
// unter „Sonstige".
|
||||||
|
const wahl = ref(false)
|
||||||
|
const wahlFehler = ref('')
|
||||||
|
const aufnahmen = ref(null)
|
||||||
|
|
||||||
|
async function wahlOeffnen() {
|
||||||
|
wahl.value = true
|
||||||
|
wahlFehler.value = ''
|
||||||
|
aufnahmen.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/aufnahmen')
|
||||||
|
aufnahmen.value = resp.ok ? (await resp.json()).stuecke : []
|
||||||
|
} catch {
|
||||||
|
aufnahmen.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function minuten(sekunden) {
|
||||||
|
return `${Math.max(1, Math.round(sekunden / 60))} Min`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erstellen(aufnahme) {
|
||||||
|
wahlFehler.value = ''
|
||||||
|
const resp = await fetch('/api/clips/von-aufnahme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ start: aufnahme.start }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const daten = await resp.json().catch(() => ({}))
|
||||||
|
wahlFehler.value = daten.detail || 'Das hat nicht geklappt.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wahl.value = false
|
||||||
|
offen.value = await resp.json()
|
||||||
|
laden_()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips = ref([])
|
||||||
|
const laden = ref(true)
|
||||||
|
const fehler = ref('')
|
||||||
|
const offen = ref(null)
|
||||||
|
|
||||||
|
async function laden_() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/clips')
|
||||||
|
if (!resp.ok) throw new Error('Clips konnten nicht geladen werden')
|
||||||
|
clips.value = await resp.json()
|
||||||
|
fehler.value = ''
|
||||||
|
// Der offene Clip soll den frischen Stand zeigen
|
||||||
|
if (offen.value) offen.value = clips.value.find((c) => c.id === offen.value.id) || null
|
||||||
|
} catch (err) {
|
||||||
|
fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Film gruppiert, der jüngste Abend zuerst
|
||||||
|
const gruppen = computed(() => {
|
||||||
|
const nach = new Map()
|
||||||
|
for (const c of clips.value) {
|
||||||
|
const key = c.movie_id ?? 0
|
||||||
|
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Sonstige', clips: [] })
|
||||||
|
nach.get(key).clips.push(c)
|
||||||
|
}
|
||||||
|
return [...nach.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
function uhrzeit(iso) {
|
||||||
|
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function datum(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let abbestellen = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
laden_()
|
||||||
|
abbestellen = abonnieren((nachricht) => {
|
||||||
|
if (nachricht.type === 'clips') laden_()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => abbestellen?.())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="clips-tab">
|
||||||
|
<div v-if="angemeldet" class="oben">
|
||||||
|
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aufnahme wählen: mit Film oder ohne -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false">
|
||||||
|
<div class="abendwahl">
|
||||||
|
<header>
|
||||||
|
<span>Welche Aufnahme?</span>
|
||||||
|
<button type="button" class="zu" @click="wahl = false">×</button>
|
||||||
|
</header>
|
||||||
|
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
|
||||||
|
<p v-if="aufnahmen === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
|
||||||
|
<p v-else-if="!aufnahmen.length" class="abendwahl-hinweis">Es gibt keine Aufnahmen.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="s in aufnahmen" :key="s.start">
|
||||||
|
<button type="button" @click="erstellen(s)">
|
||||||
|
<img
|
||||||
|
v-if="s.film?.poster_path"
|
||||||
|
:src="posterUrl(s.film.poster_path, 'w154')"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span v-else class="ohne-cover"></span>
|
||||||
|
<span class="abend-text">
|
||||||
|
<span class="abend-titel">{{ s.film?.title || 'Sonstige Aufnahme' }}</span>
|
||||||
|
<span class="abend-datum">
|
||||||
|
{{ datum(s.start) }} · {{ uhrzeit(s.start) }} Uhr · {{ minuten(s.dauer) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<p v-if="laden" class="note">Lade Clips…</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<p v-else-if="!clips.length" class="note">
|
||||||
|
Noch keine Clips. Im Stream-Reiter setzt die Taste C eine Marke.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section v-for="g in gruppen" :key="g.key">
|
||||||
|
<h3>{{ g.titel }}</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<button v-for="c in g.clips" :key="c.id" type="button" class="karte" @click="offen = c">
|
||||||
|
<!-- Geschnitten: das Video selbst als Bild. Sonst das Filmbild
|
||||||
|
mit dem Hinweis, dass hier erst eine Marke steht. -->
|
||||||
|
<span class="bild">
|
||||||
|
<ClipBild v-if="c.geschnitten" :src="`/api/clips/${c.id}/video`" />
|
||||||
|
<img v-else-if="c.poster_path" :src="backdropUrl(c.poster_path, 'w342')" alt="" />
|
||||||
|
<span v-else class="leer"></span>
|
||||||
|
<span class="dauer">{{ Math.round(c.dauer) }} s</span>
|
||||||
|
<span v-if="!c.geschnitten" class="stand">Marke</span>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="titel">{{ c.titel || 'Ohne Titel' }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
<PersonName :name="c.name" /><span
|
||||||
|
>· {{ datum(c.marke_at) }} · {{ uhrzeit(c.marke_at) }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ClipEditor
|
||||||
|
v-if="offen"
|
||||||
|
:clip="offen"
|
||||||
|
:darf="offen.mine || host"
|
||||||
|
@zu="offen = null"
|
||||||
|
@geaendert="laden_"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oben {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erstellen {
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erstellen:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bild {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
/* Sonst zieht ein hochkantiges Poster die Karte in die Länge */
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bild video,
|
||||||
|
.bild img,
|
||||||
|
.bild .leer {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bild img {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dauer,
|
||||||
|
.stand {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.45rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dauer {
|
||||||
|
right: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stand {
|
||||||
|
left: 0.45rem;
|
||||||
|
color: #f5c518;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.7rem 0.85rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titel {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Die Abendwahl liegt per Teleport im body — daher ohne Scope */
|
||||||
|
.abendwahl-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl {
|
||||||
|
display: flex;
|
||||||
|
width: min(100%, 520px);
|
||||||
|
max-height: 90vh;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl .zu {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl-fehler {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl-hinweis {
|
||||||
|
padding: 1rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl li button {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl li button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.abendwahl li img {
|
||||||
|
width: 40px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ohne Film: ein leeres Cover an Stelle des Posters */
|
||||||
|
.abendwahl li .ohne-cover {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abend-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abend-datum {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
<script setup>
|
||||||
|
// Eine Einladungskarte zum Verschicken: Titel, Datum, Uhrzeit, Genre, Ort,
|
||||||
|
// Adresse der Seite — als Bild fürs Handy. Zwei Designs: Horror und kein
|
||||||
|
// Horror. Jede Zeile ist ein Textfeld. Das Bild entsteht hier im Browser
|
||||||
|
// auf einem Canvas, der Host lädt es herunter und schickt es selbst herum.
|
||||||
|
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['zu'])
|
||||||
|
|
||||||
|
const GEMERKT = 'filmabend_karte'
|
||||||
|
const B = 1080
|
||||||
|
const H = 1350
|
||||||
|
|
||||||
|
function naechsterMittwoch() {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + ((3 - d.getDay() + 7) % 7 || 7))
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
stil: 'horror',
|
||||||
|
vorzeile: 'Einladung zum',
|
||||||
|
titel: 'Filmabend',
|
||||||
|
datum: naechsterMittwoch(),
|
||||||
|
uhrzeit: '20:00',
|
||||||
|
genre: 'Horror',
|
||||||
|
ort: 'Bananarama - Wohnzimmer',
|
||||||
|
url: 'horror.marha.de',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.assign(form, JSON.parse(localStorage.getItem(GEMERKT) || '{}'))
|
||||||
|
} catch {
|
||||||
|
// Kaputte Erinnerung — dann eben die Vorgaben
|
||||||
|
}
|
||||||
|
|
||||||
|
const leinwand = ref(null)
|
||||||
|
|
||||||
|
function stilWaehlen(stil) {
|
||||||
|
form.stil = stil
|
||||||
|
form.genre = stil === 'horror' ? 'Horror' : 'Kein Horror'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Malen ───────────────────────────────────────────────────────────
|
||||||
|
function datumText() {
|
||||||
|
const d = new Date(form.datum + 'T12:00:00')
|
||||||
|
if (Number.isNaN(d.getTime())) return form.datum
|
||||||
|
return d.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text umbrechen, damit lange Sätze nicht aus dem Bild laufen
|
||||||
|
function zeilen(ctx, text, maxBreite) {
|
||||||
|
const aus = []
|
||||||
|
let zeile = ''
|
||||||
|
for (const wort of text.split(/\s+/)) {
|
||||||
|
const probe = zeile ? `${zeile} ${wort}` : wort
|
||||||
|
if (ctx.measureText(probe).width > maxBreite && zeile) {
|
||||||
|
aus.push(zeile)
|
||||||
|
zeile = wort
|
||||||
|
} else {
|
||||||
|
zeile = probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (zeile) aus.push(zeile)
|
||||||
|
return aus
|
||||||
|
}
|
||||||
|
|
||||||
|
function rundeck(ctx, x, y, b, h, r) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, b, h, r)
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feste Zufallsfolge: Körnung und Spritzer bleiben bei jedem Tastendruck gleich
|
||||||
|
function zufall(saat) {
|
||||||
|
let z = saat
|
||||||
|
return () => {
|
||||||
|
z = (z * 1664525 + 1013904223) % 4294967296
|
||||||
|
return z / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHRIFT = 'system-ui, -apple-system, "Segoe UI", sans-serif'
|
||||||
|
|
||||||
|
const FARBEN = {
|
||||||
|
horror: { text: '#f1ecec', leise: '#9a8f92', akzent: '#e50914' },
|
||||||
|
nett: { text: '#1b2a41', leise: '#7d6b50', akzent: '#e0532f' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horror: Dunkelheit, Körnung, Spinnennetz, Blut ──────────────────
|
||||||
|
function spinnennetz(ctx, cx, cy, radius, von, bis) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
const speichen = 7
|
||||||
|
const winkel = []
|
||||||
|
for (let i = 0; i <= speichen; i++) winkel.push(von + ((bis - von) * i) / speichen)
|
||||||
|
for (const w of winkel) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(cx, cy)
|
||||||
|
ctx.lineTo(cx + Math.cos(w) * radius, cy + Math.sin(w) * radius)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
// Die Querfäden hängen leicht durch
|
||||||
|
for (let r = radius / 6; r <= radius; r += radius / 6) {
|
||||||
|
ctx.beginPath()
|
||||||
|
winkel.forEach((w, i) => {
|
||||||
|
const x = cx + Math.cos(w) * r
|
||||||
|
const y = cy + Math.sin(w) * r
|
||||||
|
if (i === 0) return ctx.moveTo(x, y)
|
||||||
|
const m = (winkel[i - 1] + w) / 2
|
||||||
|
ctx.quadraticCurveTo(cx + Math.cos(m) * r * 0.9, cy + Math.sin(m) * r * 0.9, x, y)
|
||||||
|
})
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function blut(ctx) {
|
||||||
|
ctx.fillStyle = '#b3060f'
|
||||||
|
ctx.fillRect(0, 0, B, 30)
|
||||||
|
const tropfen = [
|
||||||
|
[50, 70, 16],
|
||||||
|
[150, 140, 22],
|
||||||
|
[230, 50, 14],
|
||||||
|
[330, 180, 26],
|
||||||
|
[430, 90, 18],
|
||||||
|
[520, 220, 24],
|
||||||
|
[640, 60, 16],
|
||||||
|
[720, 150, 20],
|
||||||
|
[820, 110, 22],
|
||||||
|
[910, 200, 26],
|
||||||
|
[1000, 80, 18],
|
||||||
|
]
|
||||||
|
for (const [x, laenge, breite] of tropfen) {
|
||||||
|
rundeck(ctx, x - breite / 2, 10, breite, laenge, breite / 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, 10 + laenge, breite * 0.7, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
const r = zufall(3)
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(r() * B, 30 + r() * 260, 2 + r() * 5, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function horrorHintergrund(ctx) {
|
||||||
|
ctx.fillStyle = '#07060a'
|
||||||
|
ctx.fillRect(0, 0, B, H)
|
||||||
|
const glut = ctx.createRadialGradient(B / 2, H * 0.55, 60, B / 2, H * 0.55, H * 0.75)
|
||||||
|
glut.addColorStop(0, 'rgba(150, 10, 20, 0.35)')
|
||||||
|
glut.addColorStop(1, 'rgba(0, 0, 0, 0)')
|
||||||
|
ctx.fillStyle = glut
|
||||||
|
ctx.fillRect(0, 0, B, H)
|
||||||
|
const r = zufall(7)
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
|
||||||
|
for (let i = 0; i < 6000; i++) ctx.fillRect(r() * B, r() * H, 2, 2)
|
||||||
|
const rand = ctx.createRadialGradient(B / 2, H / 2, H * 0.35, B / 2, H / 2, H * 0.85)
|
||||||
|
rand.addColorStop(0, 'rgba(0, 0, 0, 0)')
|
||||||
|
rand.addColorStop(1, 'rgba(0, 0, 0, 0.85)')
|
||||||
|
ctx.fillStyle = rand
|
||||||
|
ctx.fillRect(0, 0, B, H)
|
||||||
|
spinnennetz(ctx, B, 0, 380, Math.PI / 2, Math.PI)
|
||||||
|
spinnennetz(ctx, 0, H, 300, -Math.PI / 2, 0)
|
||||||
|
blut(ctx)
|
||||||
|
// Ein dünner Strich zwischen Wann und Was
|
||||||
|
ctx.fillStyle = 'rgba(229, 9, 20, 0.5)'
|
||||||
|
ctx.fillRect(300, 670, B - 600, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kein Horror: ein Kinoticket auf einer Markise ───────────────────
|
||||||
|
const TICKET = { x: 70, y: 90, b: B - 140, h: H - 180, r: 36 }
|
||||||
|
|
||||||
|
function filmstreifen(ctx, y) {
|
||||||
|
ctx.fillStyle = FARBEN.nett.text
|
||||||
|
ctx.fillRect(TICKET.x, y, TICKET.b, 70)
|
||||||
|
ctx.fillStyle = '#fff4dc'
|
||||||
|
for (let x = TICKET.x + 30; x < TICKET.x + TICKET.b - 30; x += 70) {
|
||||||
|
rundeck(ctx, x, y + 20, 36, 30, 6)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nettHintergrund(ctx) {
|
||||||
|
// Markise: schräge Streifen
|
||||||
|
ctx.fillStyle = '#f2bd3a'
|
||||||
|
ctx.fillRect(0, 0, B, H)
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(B / 2, H / 2)
|
||||||
|
ctx.rotate(-Math.PI / 4)
|
||||||
|
ctx.fillStyle = '#e9b02c'
|
||||||
|
for (let x = -H; x < H; x += 80) ctx.fillRect(x, -H, 40, 2 * H)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// Das Ticket mit Schatten
|
||||||
|
ctx.save()
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
|
||||||
|
ctx.shadowBlur = 40
|
||||||
|
ctx.shadowOffsetY = 16
|
||||||
|
ctx.fillStyle = '#fff4dc'
|
||||||
|
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
|
||||||
|
ctx.clip()
|
||||||
|
filmstreifen(ctx, TICKET.y)
|
||||||
|
filmstreifen(ctx, TICKET.y + TICKET.h - 70)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// Abrisskante mit Kerben
|
||||||
|
const ky = 670
|
||||||
|
ctx.fillStyle = '#f2bd3a'
|
||||||
|
for (const x of [TICKET.x, TICKET.x + TICKET.b]) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, ky, 36, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = FARBEN.nett.text
|
||||||
|
ctx.globalAlpha = 0.35
|
||||||
|
ctx.lineWidth = 4
|
||||||
|
ctx.setLineDash([18, 14])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(TICKET.x + 60, ky)
|
||||||
|
ctx.lineTo(TICKET.x + TICKET.b - 60, ky)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text ────────────────────────────────────────────────────────────
|
||||||
|
function stempel(ctx, text, y, farbe) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(B / 2, y)
|
||||||
|
ctx.rotate(-0.06)
|
||||||
|
ctx.font = `bold 58px ${SCHRIFT}`
|
||||||
|
ctx.letterSpacing = '6px'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
const breite = Math.min(ctx.measureText(text).width + 70, B - 160)
|
||||||
|
ctx.strokeStyle = farbe
|
||||||
|
ctx.lineWidth = 6
|
||||||
|
rundeck(ctx, -breite / 2, -52, breite, 96, 12)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = farbe
|
||||||
|
ctx.fillText(text, 3, 2, breite - 40)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function malen() {
|
||||||
|
const ctx = leinwand.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const horror = form.stil === 'horror'
|
||||||
|
const farbe = FARBEN[form.stil]
|
||||||
|
if (horror) horrorHintergrund(ctx)
|
||||||
|
else nettHintergrund(ctx)
|
||||||
|
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'alphabetic'
|
||||||
|
|
||||||
|
// Vorzeile und Überschrift
|
||||||
|
ctx.fillStyle = farbe.leise
|
||||||
|
ctx.font = `600 36px ${SCHRIFT}`
|
||||||
|
ctx.letterSpacing = '8px'
|
||||||
|
ctx.fillText(form.vorzeile.toUpperCase(), B / 2, 345, B - 160)
|
||||||
|
ctx.letterSpacing = '0px'
|
||||||
|
ctx.save()
|
||||||
|
if (horror) {
|
||||||
|
ctx.shadowColor = 'rgba(229, 9, 20, 0.7)'
|
||||||
|
ctx.shadowBlur = 40
|
||||||
|
}
|
||||||
|
ctx.fillStyle = horror ? farbe.akzent : farbe.text
|
||||||
|
ctx.font = `bold 104px ${SCHRIFT}`
|
||||||
|
ctx.letterSpacing = horror ? '10px' : '2px'
|
||||||
|
const titel = horror ? form.titel.toUpperCase() : form.titel
|
||||||
|
ctx.fillText(titel, B / 2, 440, B - 160)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// Wann
|
||||||
|
ctx.fillStyle = farbe.text
|
||||||
|
ctx.font = `600 50px ${SCHRIFT}`
|
||||||
|
ctx.fillText(datumText(), B / 2, 540, B - 160)
|
||||||
|
ctx.fillStyle = farbe.leise
|
||||||
|
ctx.font = `42px ${SCHRIFT}`
|
||||||
|
if (form.uhrzeit) ctx.fillText(`${form.uhrzeit} Uhr`, B / 2, 600, B - 160)
|
||||||
|
|
||||||
|
// Genre als Stempel
|
||||||
|
stempel(ctx, form.genre.toUpperCase(), 770, farbe.akzent)
|
||||||
|
|
||||||
|
// Kleine Titel über Discord und Webseite
|
||||||
|
const titelZeile = (text, y) => {
|
||||||
|
ctx.fillStyle = farbe.leise
|
||||||
|
ctx.font = `600 30px ${SCHRIFT}`
|
||||||
|
ctx.letterSpacing = '4px'
|
||||||
|
ctx.fillText(text, B / 2, y)
|
||||||
|
ctx.letterSpacing = '0px'
|
||||||
|
}
|
||||||
|
|
||||||
|
titelZeile('DISCORD', 890)
|
||||||
|
ctx.fillStyle = farbe.text
|
||||||
|
ctx.font = `46px ${SCHRIFT}`
|
||||||
|
let y = 950
|
||||||
|
for (const z of zeilen(ctx, form.ort, B - 200)) {
|
||||||
|
ctx.fillText(z, B / 2, y)
|
||||||
|
y += 56
|
||||||
|
}
|
||||||
|
|
||||||
|
titelZeile('WEBSEITE', y + 50)
|
||||||
|
ctx.fillStyle = farbe.text
|
||||||
|
ctx.font = `48px ${SCHRIFT}`
|
||||||
|
ctx.fillText(form.url, B / 2, y + 110, B - 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichern() {
|
||||||
|
leinwand.value.toBlob((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `einladung-${form.datum}.png`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(form, () => {
|
||||||
|
localStorage.setItem(GEMERKT, JSON.stringify(form))
|
||||||
|
malen()
|
||||||
|
})
|
||||||
|
|
||||||
|
function escape(e) {
|
||||||
|
if (e.key === 'Escape') emit('zu')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', escape)
|
||||||
|
malen()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', escape))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="hintergrund" @click.self="emit('zu')">
|
||||||
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
|
<div class="formular">
|
||||||
|
<p class="label">Einladung</p>
|
||||||
|
<div class="stil">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ an: form.stil === 'horror' }"
|
||||||
|
@click="stilWaehlen('horror')"
|
||||||
|
>
|
||||||
|
Horror
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ an: form.stil === 'nett' }"
|
||||||
|
@click="stilWaehlen('nett')"
|
||||||
|
>
|
||||||
|
Kein Horror
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zeile">
|
||||||
|
<label>
|
||||||
|
Vorzeile
|
||||||
|
<input v-model="form.vorzeile" type="text" maxlength="30" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Überschrift
|
||||||
|
<input v-model="form.titel" type="text" maxlength="24" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="zeile">
|
||||||
|
<label>
|
||||||
|
Datum
|
||||||
|
<input v-model="form.datum" type="date" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Uhrzeit
|
||||||
|
<input v-model="form.uhrzeit" type="time" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Genre
|
||||||
|
<input v-model="form.genre" type="text" maxlength="24" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Discord
|
||||||
|
<input v-model="form.ort" type="text" maxlength="60" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Webseite
|
||||||
|
<input v-model="form.url" type="text" maxlength="60" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="fuss">
|
||||||
|
<button type="button" class="speichern" @click="speichern">Als Bild speichern</button>
|
||||||
|
<button type="button" class="abbrechen" @click="emit('zu')">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref="leinwand" class="vorschau" :width="B" :height="H"></canvas>
|
||||||
|
</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: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: min(52rem, 100%);
|
||||||
|
max-height: 92vh;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stil {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stil button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stil button.an {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='date'],
|
||||||
|
input[type='time'] {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speichern {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abbrechen {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Vorschau: das volle Bild, nur kleiner gezeigt */
|
||||||
|
.vorschau {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.fenster {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschau {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -12,6 +12,12 @@ const props = defineProps({
|
|||||||
ohneSuche: Boolean,
|
ohneSuche: Boolean,
|
||||||
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
||||||
mitRelevanz: Boolean,
|
mitRelevanz: Boolean,
|
||||||
|
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
|
||||||
|
ohneSort: Boolean,
|
||||||
|
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
|
||||||
|
quellen: { type: Array, default: () => [] },
|
||||||
|
// Die Suchen lassen wählen, wie viele Treffer es sein sollen
|
||||||
|
mitAnzahl: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||||
@@ -19,7 +25,13 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
|
|||||||
const search = defineModel('search', { type: String, default: '' })
|
const search = defineModel('search', { type: String, default: '' })
|
||||||
const sort = defineModel('sort', { type: String, default: 'random' })
|
const sort = defineModel('sort', { type: String, default: 'random' })
|
||||||
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
|
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
|
||||||
|
const ohneGesehene = defineModel('ohneGesehene', {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||||
|
const quelle = defineModel('quelle', { type: String, default: '' })
|
||||||
|
const anzahl = defineModel('anzahl', { type: Number, default: 24 })
|
||||||
|
|
||||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||||
const etwasAktiv = computed(
|
const etwasAktiv = computed(
|
||||||
@@ -30,7 +42,7 @@ const etwasAktiv = computed(
|
|||||||
const spanne = ranges.value[r.key]
|
const spanne = ranges.value[r.key]
|
||||||
const grenze = props.grenzen[r.key]
|
const grenze = props.grenzen[r.key]
|
||||||
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function beschriften(regler) {
|
function beschriften(regler) {
|
||||||
@@ -44,19 +56,26 @@ function beschriften(regler) {
|
|||||||
return zahl + (regler.einheit || '')
|
return zahl + (regler.einheit || '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input
|
<div v-if="quellen.length" class="ansicht">
|
||||||
v-if="!ohneSuche"
|
<button
|
||||||
v-model="search"
|
v-for="q in quellen"
|
||||||
type="search"
|
:key="q.id"
|
||||||
placeholder="Titel suchen…"
|
type="button"
|
||||||
/>
|
:class="{ an: quelle === q.id }"
|
||||||
<select v-model="sort">
|
@click="quelle = q.id"
|
||||||
|
>
|
||||||
|
{{ q.label }}
|
||||||
|
<span v-if="q.count != null" class="zahl">{{ q.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
|
||||||
|
<select v-if="!ohneSort" v-model="sort">
|
||||||
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||||
<option value="random">Zufällig</option>
|
<option value="random">Zufällig</option>
|
||||||
<option value="rating">Beste Bewertung</option>
|
<option value="rating">Beste Bewertung</option>
|
||||||
@@ -65,22 +84,24 @@ function beschriften(regler) {
|
|||||||
<option value="title">Titel A–Z</option>
|
<option value="title">Titel A–Z</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label v-if="mitAnzahl" class="anzahl">
|
||||||
|
<span>Ergebnisse <b>{{ anzahl }}</b></span>
|
||||||
|
<input v-model.number="anzahl" type="range" min="1" max="100" />
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="ansicht">
|
<div class="ansicht">
|
||||||
<button
|
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
|
||||||
type="button"
|
|
||||||
:class="{ an: ansicht === 'kompakt' }"
|
|
||||||
@click="ansicht = 'kompakt'"
|
|
||||||
>
|
|
||||||
Kompakt
|
Kompakt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
|
||||||
type="button"
|
|
||||||
:class="{ an: ansicht === 'details' }"
|
|
||||||
@click="ansicht = 'details'"
|
|
||||||
>
|
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="gesehen-schalter">
|
||||||
|
<input v-model="ohneGesehene" type="checkbox" />
|
||||||
|
Gesehene ausblenden
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -158,6 +179,25 @@ function beschriften(regler) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.gesehen-schalter {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
/* Eigene Zeile unter Kompakt/Details, sonst quetscht er sich daneben */
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .controls input zieht sonst auf volle Breite — die Checkbox bleibt schmal */
|
||||||
|
.controls .gesehen-schalter input {
|
||||||
|
width: auto;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -266,11 +306,34 @@ h4 {
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anzahl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anzahl b {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .anzahl input {
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.ansicht {
|
.ansicht {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ansicht .zahl {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.ansicht button {
|
.ansicht button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
@@ -290,4 +353,47 @@ h4 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Finger statt Maus: alles etwas größer. 16 px in den Feldern, sonst
|
||||||
|
zoomt iOS beim Tippen in die Seite. */
|
||||||
|
@media (max-width: 640px), (pointer: coarse) {
|
||||||
|
.sidebar {
|
||||||
|
gap: 1.4rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input,
|
||||||
|
.controls select {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .gesehen-schalter {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .gesehen-schalter input {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
<script setup>
|
||||||
|
// Ein Markdown-Text, mehr nicht: PayPal, IBAN, Regeln, was man wissen
|
||||||
|
// muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage
|
||||||
|
// mit den Punkteregeln.
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { abonnieren } from '../live'
|
||||||
|
import { rendern } from '../markdown'
|
||||||
|
import MarkdownFeld from './MarkdownFeld.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
host: Boolean,
|
||||||
|
// Die Runde — daraus wählt der Host, wer ein Blatt bekommt
|
||||||
|
runde: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const laden = ref(true)
|
||||||
|
const bearbeitet = ref(false)
|
||||||
|
const entwurf = ref('')
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
async function laden_() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/info')
|
||||||
|
if (!resp.ok) throw new Error('Infos konnten nicht geladen werden')
|
||||||
|
text.value = (await resp.json()).text
|
||||||
|
fehler.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearbeiten() {
|
||||||
|
entwurf.value = text.value
|
||||||
|
bearbeitet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
|
const resp = await fetch('/api/info', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: entwurf.value }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
fehler.value = 'Speichern hat nicht geklappt.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bearbeitet.value = false
|
||||||
|
laden_()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spendenbaum: ein Blatt je Person, das neueste oben ─────────────────
|
||||||
|
const blaetter = ref([])
|
||||||
|
const gewaehlt = ref('')
|
||||||
|
|
||||||
|
// Der Kontostand läuft als Erstes über den Bildschirm
|
||||||
|
const kontostand = ref(null)
|
||||||
|
const standEntwurf = ref('')
|
||||||
|
|
||||||
|
async function baumLaden() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/spenden')
|
||||||
|
if (resp.ok) blaetter.value = await resp.json()
|
||||||
|
const stand = await fetch('/api/kontostand')
|
||||||
|
if (stand.ok) kontostand.value = (await stand.json()).betrag
|
||||||
|
} catch {
|
||||||
|
// Der Abspann ist Beiwerk — ohne ihn geht die Seite trotzdem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Wartezeit vor dem Abspann gilt nur für den ersten Durchlauf. Nach
|
||||||
|
// jedem Durchlauf die Animation neu ansetzen — dann wartet sie wieder.
|
||||||
|
function neuAnsetzen(e) {
|
||||||
|
const el = e.target
|
||||||
|
el.style.animationName = 'none'
|
||||||
|
void el.offsetHeight
|
||||||
|
el.style.animationName = ''
|
||||||
|
mischen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function euro(betrag) {
|
||||||
|
return betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function standSetzen() {
|
||||||
|
const betrag = standEntwurf.value === '' ? null : Number(standEntwurf.value)
|
||||||
|
const resp = await fetch('/api/kontostand', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ betrag }),
|
||||||
|
})
|
||||||
|
if (resp.ok) kontostand.value = (await resp.json()).betrag
|
||||||
|
standEntwurf.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Im Abspann stehen die Namen bei jedem Durchlauf in anderer Reihenfolge —
|
||||||
|
// niemand steht immer oben
|
||||||
|
const gemischt = ref([])
|
||||||
|
|
||||||
|
function mischen() {
|
||||||
|
const liste = [...blaetter.value]
|
||||||
|
for (let i = liste.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[liste[i], liste[j]] = [liste[j], liste[i]]
|
||||||
|
}
|
||||||
|
gemischt.value = liste
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(blaetter, mischen)
|
||||||
|
|
||||||
|
// Wer noch kein Blatt hat
|
||||||
|
const ohneBlatt = computed(() =>
|
||||||
|
props.runde.filter((u) => !blaetter.value.some((b) => b.user_id === u.id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function blattSetzen() {
|
||||||
|
if (!gewaehlt.value) return
|
||||||
|
const resp = await fetch('/api/spenden', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: Number(gewaehlt.value) }),
|
||||||
|
})
|
||||||
|
if (resp.ok) blaetter.value = await resp.json()
|
||||||
|
gewaehlt.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blattWeg(userId) {
|
||||||
|
const resp = await fetch(`/api/spenden/${userId}`, { method: 'DELETE' })
|
||||||
|
if (resp.ok) blaetter.value = await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
let abbestellen = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
laden_()
|
||||||
|
baumLaden()
|
||||||
|
abbestellen = abonnieren((nachricht) => {
|
||||||
|
if (nachricht.type === 'info') laden_()
|
||||||
|
else if (nachricht.type === 'spenden') baumLaden()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => abbestellen?.())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="info-tab">
|
||||||
|
<div class="text">
|
||||||
|
<p v-if="laden" class="note">Lade …</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<MarkdownFeld
|
||||||
|
v-else-if="bearbeitet"
|
||||||
|
v-model="entwurf"
|
||||||
|
platzhalter="PayPal, IBAN, Regeln, was man wissen muss …"
|
||||||
|
knopf="Speichern"
|
||||||
|
:maxlaenge="8000"
|
||||||
|
@senden="speichern"
|
||||||
|
@abbrechen="bearbeitet = false"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="oben">
|
||||||
|
<button v-if="host" type="button" class="klein" @click="bearbeiten">Bearbeiten</button>
|
||||||
|
</div>
|
||||||
|
<div class="markdown" v-html="rendern(text)"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="abspann">
|
||||||
|
<!-- Ein Röhrenfernseher: im Bild läuft der Abspann mit den Namen -->
|
||||||
|
<div class="tv">
|
||||||
|
<svg class="gehaeuse" viewBox="0 0 320 262" aria-hidden="true">
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="6"
|
||||||
|
width="308"
|
||||||
|
height="218"
|
||||||
|
rx="24"
|
||||||
|
fill="#2b2522"
|
||||||
|
stroke="#171311"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
<rect x="22" y="22" width="240" height="182" rx="24" fill="#0a0a0c" />
|
||||||
|
<circle cx="288" cy="64" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
|
||||||
|
<rect x="287" y="53" width="2" height="8" fill="#171311" />
|
||||||
|
<circle cx="288" cy="106" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
|
||||||
|
<rect x="287" y="95" width="2" height="8" fill="#171311" />
|
||||||
|
<g fill="#171311">
|
||||||
|
<rect x="278" y="140" width="20" height="3" rx="1.5" />
|
||||||
|
<rect x="278" y="148" width="20" height="3" rx="1.5" />
|
||||||
|
<rect x="278" y="156" width="20" height="3" rx="1.5" />
|
||||||
|
<rect x="278" y="164" width="20" height="3" rx="1.5" />
|
||||||
|
<rect x="278" y="172" width="20" height="3" rx="1.5" />
|
||||||
|
</g>
|
||||||
|
<rect x="56" y="224" width="26" height="34" rx="5" fill="#171311" />
|
||||||
|
<rect x="238" y="224" width="26" height="34" rx="5" fill="#171311" />
|
||||||
|
</svg>
|
||||||
|
<div class="schirm">
|
||||||
|
<!-- Der Abspann: der Kontostand führt ihn an. Er steht beim Start
|
||||||
|
schon im Bild, wartet kurz und läuft dann nach oben hinaus -->
|
||||||
|
<div class="lauf">
|
||||||
|
<div
|
||||||
|
v-if="blaetter.length || kontostand !== null"
|
||||||
|
class="rolle"
|
||||||
|
:style="{ animationDuration: `1s, ${5 + blaetter.length * 1.5}s` }"
|
||||||
|
@animationiteration="neuAnsetzen"
|
||||||
|
>
|
||||||
|
<template v-if="kontostand !== null">
|
||||||
|
<p class="vorspann">Kontostand</p>
|
||||||
|
<p class="betrag">{{ euro(kontostand) }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-if="blaetter.length">
|
||||||
|
<p class="vorspann">Supporter</p>
|
||||||
|
<p v-for="b in gemischt" :key="b.user_id" class="name">{{ b.name }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-else class="niemand">Noch niemand</p>
|
||||||
|
</div>
|
||||||
|
<span class="scanlines"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="host">
|
||||||
|
<form class="eintrag" @submit.prevent="standSetzen">
|
||||||
|
<input
|
||||||
|
v-model="standEntwurf"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:placeholder="kontostand === null ? 'Kontostand in €' : euro(kontostand)"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="klein">Speichern</button>
|
||||||
|
</form>
|
||||||
|
<form class="eintrag" @submit.prevent="blattSetzen">
|
||||||
|
<select v-model="gewaehlt">
|
||||||
|
<option value="">Name …</option>
|
||||||
|
<option v-for="u in ohneBlatt" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="klein" :disabled="!gewaehlt">Eintragen</button>
|
||||||
|
</form>
|
||||||
|
<ul v-if="blaetter.length" class="liste">
|
||||||
|
<li v-for="b in blaetter" :key="b.user_id">
|
||||||
|
{{ b.name }}
|
||||||
|
<button type="button" class="weg" title="Entfernen" @click="blattWeg(b.user_id)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-tab {
|
||||||
|
display: grid;
|
||||||
|
/* Zwei gleiche Spalten, links der Text, rechts der Baum — als Block
|
||||||
|
in der Mitte, mit Luft zu den Rändern */
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: start;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abspann {
|
||||||
|
/* Feste Breite: mit automatischem Rand schrumpft ein Rasterfeld sonst
|
||||||
|
auf die Eigengröße des SVG zusammen */
|
||||||
|
width: min(100%, 450px);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Der Fernseher: Gehäuse als SVG, der Bildschirm liegt darüber ───── */
|
||||||
|
.tv {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 320 / 262;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gehaeuse {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deckt genau die Bildschirmmulde des SVG ab (22/22, 240×182 von 320×262) */
|
||||||
|
.schirm {
|
||||||
|
position: absolute;
|
||||||
|
top: 8.4%;
|
||||||
|
left: 6.9%;
|
||||||
|
width: 75%;
|
||||||
|
height: 69.5%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(ellipse at center, #171a1f 0%, #0a0a0c 75%);
|
||||||
|
border-radius: 9% / 12%;
|
||||||
|
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Zeilenstruktur einer Röhre */
|
||||||
|
.scanlines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.28) 0 2px, transparent 2px 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Abspann: steht beim Start im Bild, läuft nach oben hinaus, von vorn */
|
||||||
|
.lauf {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rolle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
/* Die erste Zeile startet in der Bildmitte und blendet eine Sekunde
|
||||||
|
lang ein, dann rollt alles nach oben hinaus. Die Dauern setzt die
|
||||||
|
Vorlage: 1 s fürs Einblenden, der Rest nach Anzahl der Namen */
|
||||||
|
animation:
|
||||||
|
einblenden ease-out both,
|
||||||
|
abspann linear infinite;
|
||||||
|
animation-delay: 0s, 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes einblenden {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.schirm:hover .rolle {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Am Ende liegt die Unterkante über dem Bildrand: top auf 0, dazu die
|
||||||
|
eigene Höhe nach oben */
|
||||||
|
@keyframes abspann {
|
||||||
|
from {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorspann {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #d9dde2;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betrag {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #d9dde2;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.niemand {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag select,
|
||||||
|
.eintrag input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Namen zum Entfernen — nur der Host sieht die Liste */
|
||||||
|
.liste {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste li {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weg {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weg:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.info-tab {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oben {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klein {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klein:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup>
|
||||||
|
// Filme finden, indem man beschreibt, was man sucht. Die KI schlägt vor,
|
||||||
|
// TMDB bestätigt — was es dort nicht gibt, kommt nicht an. Auf Wunsch
|
||||||
|
// geht die eigene Sammlung mit, dann kennt die KI den Geschmack. Die
|
||||||
|
// Filterleiste geht als Vorgabe mit und siebt hinterher noch einmal.
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import MovieCard from './MovieCard.vue'
|
||||||
|
import { filterParameter } from '../filter.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
angemeldet: Boolean,
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
ohneGesehene: { type: Boolean, default: true },
|
||||||
|
filter: { type: Object, default: () => ({}) },
|
||||||
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['open'])
|
||||||
|
|
||||||
|
const MAX = 1000
|
||||||
|
|
||||||
|
const beschreibung = ref('')
|
||||||
|
const mitSammlung = ref(false)
|
||||||
|
const vorschlaege = ref([])
|
||||||
|
const laden = ref(false)
|
||||||
|
const fehler = ref('')
|
||||||
|
const gefragt = ref(false)
|
||||||
|
|
||||||
|
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
|
||||||
|
const leisteVerstellt = computed(() => {
|
||||||
|
const { sort, limit, ...rest } = filterParameter(props.filter)
|
||||||
|
return Object.values(rest).some((w) => w !== '' && w != null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const kannFragen = computed(
|
||||||
|
() =>
|
||||||
|
!laden.value &&
|
||||||
|
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fragen() {
|
||||||
|
if (!kannFragen.value) return
|
||||||
|
laden.value = true
|
||||||
|
fehler.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/ki-suche', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
beschreibung: beschreibung.value.trim(),
|
||||||
|
mit_sammlung: props.angemeldet && mitSammlung.value,
|
||||||
|
ohne_gesehene: props.ohneGesehene,
|
||||||
|
...filterParameter(props.filter),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const daten = await resp.json().catch(() => ({}))
|
||||||
|
throw new Error(daten.detail || 'Das hat nicht geklappt.')
|
||||||
|
}
|
||||||
|
vorschlaege.value = await resp.json()
|
||||||
|
gefragt.value = true
|
||||||
|
} catch (err) {
|
||||||
|
fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(movie) {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok) emit('open', await resp.json())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ki-tab">
|
||||||
|
<div class="frage">
|
||||||
|
<textarea
|
||||||
|
v-model="beschreibung"
|
||||||
|
:maxlength="MAX"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Was suchst du?"
|
||||||
|
@keydown.ctrl.enter="fragen"
|
||||||
|
></textarea>
|
||||||
|
<div class="zeile">
|
||||||
|
<label v-if="angemeldet" class="schalter">
|
||||||
|
<input v-model="mitSammlung" type="checkbox" />
|
||||||
|
Meine Sammlung mitgeben
|
||||||
|
</label>
|
||||||
|
<span class="rest"></span>
|
||||||
|
<button type="button" class="los" :disabled="!kannFragen" @click="fragen">
|
||||||
|
{{ laden ? 'Sucht …' : 'Suchen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="laden" class="note">Die KI überlegt. Das dauert ein paar Sekunden.</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<p v-else-if="gefragt && !vorschlaege.length" class="note">
|
||||||
|
Nichts Brauchbares gefunden. Beschreib es anders.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Wo sonst die Inhaltsangabe steht, sagt hier die KI, warum der
|
||||||
|
Film passt -->
|
||||||
|
<div class="grid" :class="ansicht">
|
||||||
|
<MovieCard
|
||||||
|
v-for="movie in vorschlaege"
|
||||||
|
:key="movie.id"
|
||||||
|
:movie="{ ...movie, teaser: movie.grund }"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
:gesehen="gesehenIds.has(movie.id)"
|
||||||
|
@open="open"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ki-tab {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 5.5rem;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los {
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.los:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wie in der Suche: Poster im Raster, Detailkarten zu zweit nebeneinander */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<script setup>
|
||||||
|
// Ein Kommentar in der Diskussion: der Text, darunter Herz, 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 { computed, ref } from 'vue'
|
||||||
|
import { rendern } from '../markdown'
|
||||||
|
import MarkdownFeld from './MarkdownFeld.vue'
|
||||||
|
import PersonName from './PersonName.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 herz() {
|
||||||
|
if (!props.angemeldet) return
|
||||||
|
await fetch('/api/watched/hearts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note_id: props.kommentar.id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wer das Herz gegeben hat, sieht nur der Host — als Tooltip
|
||||||
|
const herzTitel = computed(() => {
|
||||||
|
const namen = props.host ? props.kommentar.hearted_by || [] : []
|
||||||
|
if (namen.length) return namen.join(', ')
|
||||||
|
return props.kommentar.hearted ? 'Herz zurücknehmen' : 'Sehe ich auch so'
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pille"
|
||||||
|
:class="{ an: kommentar.hearted, hat: kommentar.hearts > 0 }"
|
||||||
|
:disabled="!angemeldet"
|
||||||
|
:title="herzTitel"
|
||||||
|
@click="herz"
|
||||||
|
>
|
||||||
|
♥ {{ kommentar.hearts }}
|
||||||
|
</button>
|
||||||
|
<span :class="{ eigen: kommentar.mine }"><PersonName :name="kommentar.name" /></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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Herz klein vor dem Namen, in der Zeile mit Datum und Antworten */
|
||||||
|
.pille {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pille:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pille.hat {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pille.an {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pille:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inhalt {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Herz, Name, Datum und Antworten mittig auf einer Linie */
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
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) {
|
||||||
|
.pille {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link,
|
||||||
|
.weg {
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,40 +1,59 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } 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 },
|
ich: { type: Object, default: null },
|
||||||
|
// Eine eigene Sammlung hat nur, wer in der Runde steht
|
||||||
|
angemeldet: Boolean,
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return [...nach.values()].sort((a, b) => b.mine - a.mine)
|
return [...nach.values()].sort((a, b) => b.mine - a.mine)
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open', 'changed'])
|
// Die Karte braucht die Kennung des Films, nicht die des Eintrags
|
||||||
|
function alsFilm(item) {
|
||||||
|
return { ...item, id: item.movie_id }
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -44,91 +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">{{ gruppe.filme.length }}</span>
|
<span class="anzahl">{{ gruppe.filme.length }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid" :class="ansicht">
|
||||||
<article v-for="item in gruppe.filme" :key="item.id" class="karte">
|
<!-- Der Entfernen-Knopf liegt über der Karte statt darin: eine
|
||||||
<button type="button" class="cover" @click="oeffnen(item)">
|
Karte ist selbst ein Knopf, und Knöpfe schachtelt man nicht -->
|
||||||
<img
|
<div v-for="item in gruppe.filme" :key="item.id" class="halter">
|
||||||
v-if="posterUrl(item.poster_path, 'w342')"
|
<MovieCard
|
||||||
:src="posterUrl(item.poster_path, 'w342')"
|
:movie="alsFilm(item)"
|
||||||
:alt="item.title"
|
:ansicht="ansicht"
|
||||||
loading="lazy"
|
:gesehen="gesehenIds.has(item.movie_id)"
|
||||||
/>
|
@open="oeffnen"
|
||||||
<span v-else class="kein-cover">Kein Cover</span>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="item.mine"
|
||||||
|
type="button"
|
||||||
|
class="weg"
|
||||||
|
title="Aus deiner Sammlung nehmen"
|
||||||
|
@click="vergessen(item)"
|
||||||
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<div class="text">
|
|
||||||
<h4 @click="oeffnen(item)">{{ item.title }}</h4>
|
|
||||||
<p v-if="item.year" class="jahr">{{ item.year }}</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -151,34 +127,58 @@ h3 {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
.grid {
|
||||||
font-size: 0.98rem;
|
display: grid;
|
||||||
line-height: 1.25;
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
cursor: pointer;
|
gap: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4:hover {
|
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
|
||||||
color: var(--accent);
|
oder die halbe Reihe, falls die schmaler ist. */
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: repeat(
|
||||||
|
auto-fill,
|
||||||
|
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.jahr {
|
.halter {
|
||||||
color: var(--muted);
|
position: relative;
|
||||||
font-size: 0.8rem;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -186,4 +186,15 @@ h4: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>
|
||||||
@@ -5,6 +5,8 @@ import { backdropUrl, posterUrl } from '../images'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
movie: { type: Object, required: true },
|
movie: { type: Object, required: true },
|
||||||
aufDemRad: { type: Boolean, default: false },
|
aufDemRad: { type: Boolean, default: false },
|
||||||
|
// Schon gelaufen — die Karte tritt dann zurück
|
||||||
|
gesehen: { type: Boolean, default: false },
|
||||||
ansicht: { type: String, default: 'kompakt' },
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ const meta = computed(() =>
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="card"
|
class="card"
|
||||||
:class="[ansicht, { 'auf-dem-rad': aufDemRad }]"
|
:class="[ansicht, { 'auf-dem-rad': aufDemRad, gesehen }]"
|
||||||
type="button"
|
type="button"
|
||||||
@click="$emit('open', movie)"
|
@click="$emit('open', movie)"
|
||||||
>
|
>
|
||||||
@@ -69,6 +71,18 @@ const meta = computed(() =>
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Schon gelaufen — die Karte tritt zurück, bleibt aber lesbar.
|
||||||
|
Beim Überfahren kommt sie zurück. */
|
||||||
|
.card.gesehen {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.gesehen:hover {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */
|
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */
|
||||||
.haupt {
|
.haupt {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -244,4 +258,49 @@ h2 {
|
|||||||
.card.details .shots {
|
.card.details .shots {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schmal und kompakt: nur Poster, Titel und Jahr */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.card.kompakt .body {
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt h2 {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
min-height: calc(2 * 1.25em);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt .meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt .teaser {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.card.details {
|
||||||
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.details .poster {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.details .shots {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -7,12 +7,14 @@ const props = defineProps({
|
|||||||
series: { type: Array, default: () => [] },
|
series: { type: Array, default: () => [] },
|
||||||
meinVorschlag: { type: Object, default: null },
|
meinVorschlag: { type: Object, default: null },
|
||||||
host: { type: Boolean, default: false },
|
host: { type: Boolean, default: false },
|
||||||
|
// Ohne gewählten Namen gibt es keine Sammlung und keinen Vorschlag
|
||||||
|
angemeldet: { type: Boolean, default: false },
|
||||||
gesehen: { type: Boolean, default: false },
|
gesehen: { type: Boolean, default: false },
|
||||||
gemerkt: { type: Boolean, default: false },
|
gemerkt: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
|
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
|
||||||
])
|
])
|
||||||
|
|
||||||
const vorschlagen = ref({ zustand: 'bereit' })
|
const vorschlagen = ref({ zustand: 'bereit' })
|
||||||
@@ -107,11 +109,25 @@ async function ladeAehnliche(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regie und Besetzung — jeder Name führt zur Personensuche
|
||||||
|
const leute = ref({ regie: [], cast: [] })
|
||||||
|
|
||||||
|
async function ladeLeute(id) {
|
||||||
|
leute.value = { regie: [], cast: [] }
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/movies/${id}/credits`)
|
||||||
|
if (resp.ok && props.movie.id === id) leute.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann eben ohne */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.movie.id,
|
() => props.movie.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
vorschlagen.value = { zustand: 'bereit' }
|
vorschlagen.value = { zustand: 'bereit' }
|
||||||
ladeAehnliche(id)
|
ladeAehnliche(id)
|
||||||
|
ladeLeute(id)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -174,7 +190,13 @@ onUnmounted(() => {
|
|||||||
<header>
|
<header>
|
||||||
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
||||||
<div class="head-text">
|
<div class="head-text">
|
||||||
<h2>{{ movie.title }}</h2>
|
<h2>
|
||||||
|
{{ movie.title }}
|
||||||
|
<!-- Läuft nur im Abo: dann bleibt fürs Leihen nichts übrig -->
|
||||||
|
<span v-if="movie.rentable != null && !movie.rentable" class="nicht-leihbar">
|
||||||
|
Nicht leihbar
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
<p v-if="movie.original_title !== movie.title" class="original">
|
<p v-if="movie.original_title !== movie.title" class="original">
|
||||||
{{ movie.original_title }}
|
{{ movie.original_title }}
|
||||||
</p>
|
</p>
|
||||||
@@ -186,6 +208,22 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||||
|
|
||||||
|
<p v-for="[titel, liste] in [['Regie', leute.regie], ['Mit', leute.cast]]" :key="titel">
|
||||||
|
<template v-if="liste.length">
|
||||||
|
<span class="leute-titel">{{ titel }}</span>
|
||||||
|
<button
|
||||||
|
v-for="p in liste"
|
||||||
|
:key="p.id"
|
||||||
|
type="button"
|
||||||
|
class="person"
|
||||||
|
:title="`Filme mit ${p.name}`"
|
||||||
|
@click="$emit('person', p)"
|
||||||
|
>
|
||||||
|
{{ p.name }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a
|
<a
|
||||||
@@ -206,6 +244,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
IMDb
|
IMDb
|
||||||
</a>
|
</a>
|
||||||
|
<!-- TMDBs Anbieterliste für Deutschland: wo man leihen, kaufen
|
||||||
|
oder streamen kann. IMDb zeigt davon nur Amazon. -->
|
||||||
|
<a
|
||||||
|
class="tmdb"
|
||||||
|
:href="`https://www.themoviedb.org/movie/${movie.id}/watch?locale=DE`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
TMDB
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vorschlag">
|
<div class="vorschlag">
|
||||||
@@ -213,7 +261,7 @@ onUnmounted(() => {
|
|||||||
Ähnliche finden
|
Ähnliche finden
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!host"
|
v-if="!host && angemeldet"
|
||||||
type="button"
|
type="button"
|
||||||
class="merken"
|
class="merken"
|
||||||
:class="{ an: gemerkt }"
|
:class="{ an: gemerkt }"
|
||||||
@@ -221,7 +269,7 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
|
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="!host">
|
<template v-if="!host && angemeldet">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="merken"
|
class="merken"
|
||||||
@@ -235,14 +283,15 @@ onUnmounted(() => {
|
|||||||
Hat nicht geklappt.
|
Hat nicht geklappt.
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Ohne Namen fehlen hier zwei Knöpfe — sonst sucht man
|
||||||
|
vergeblich, warum -->
|
||||||
|
<p v-if="!host && !angemeldet" class="hinweis">
|
||||||
|
Zum Merken und Vorschlagen wähle oben rechts deinen Namen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="host || gesehen" class="gesehen">
|
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
|
||||||
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
|
|
||||||
Steht unter „Gesehen“.
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
v-else-if="host"
|
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="merken.zustand === 'laeuft'"
|
:disabled="merken.zustand === 'laeuft'"
|
||||||
@click="alsGesehen"
|
@click="alsGesehen"
|
||||||
@@ -400,7 +449,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.sheet {
|
.sheet {
|
||||||
grid-template-columns: 1fr;
|
/* minmax(0, …): sonst hält die Spalte die Breite ihres breitesten
|
||||||
|
Kindes und die ganze Ansicht läuft seitlich aus dem Schirm */
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet > * {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet > header,
|
.sheet > header,
|
||||||
@@ -472,6 +527,26 @@ h2 {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leute-titel {
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Namen als kleine Pillen, ein Klick führt zur Personensuche */
|
||||||
|
.person {
|
||||||
|
margin: 0 0.3rem 0.3rem 0;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.teaser {
|
.teaser {
|
||||||
color: #cfcfda;
|
color: #cfcfda;
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
@@ -485,7 +560,8 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.netflix,
|
.netflix,
|
||||||
.imdb {
|
.imdb,
|
||||||
|
.tmdb {
|
||||||
padding: 0.55rem 1.1rem;
|
padding: 0.55rem 1.1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -497,11 +573,29 @@ h2 {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tmdb {
|
||||||
|
color: #0d253f;
|
||||||
|
background: #01b4e4;
|
||||||
|
}
|
||||||
|
|
||||||
.imdb {
|
.imdb {
|
||||||
color: #111;
|
color: #111;
|
||||||
background: #f5c518;
|
background: #f5c518;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nicht-leihbar {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||||
Trennstrich nur so breit wie die Knöpfe darunter */
|
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||||
.vorschlag {
|
.vorschlag {
|
||||||
@@ -596,7 +690,8 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.netflix:hover,
|
.netflix:hover,
|
||||||
.imdb:hover {
|
.imdb:hover,
|
||||||
|
.tmdb:hover {
|
||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,6 +796,46 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
.backdrop {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Schließen-Knopf bleibt beim Scrollen im Bild */
|
||||||
|
.close {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vorschlag button,
|
||||||
|
.gesehen button,
|
||||||
|
.merken,
|
||||||
|
.netflix,
|
||||||
|
.imdb,
|
||||||
|
.tmdb {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -712,6 +847,10 @@ h3 {
|
|||||||
.shots {
|
.shots {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lupe {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shot {
|
.shot {
|
||||||
|
|||||||
@@ -1,21 +1,78 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import FilmLogin from './FilmLogin.vue'
|
||||||
|
import PersonName from './PersonName.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)
|
||||||
|
|
||||||
|
// Der eigene Eintrag aus der Runde — der trägt Punkte und Level
|
||||||
|
const meins = computed(() => props.items.find((u) => u.id === props.mine?.id) || null)
|
||||||
|
|
||||||
|
// Wie weit der Balken zum nächsten Level gefüllt ist
|
||||||
|
function anteil(u) {
|
||||||
|
const weg = u.bis - u.von
|
||||||
|
return weg > 0 ? Math.round(((u.punkte - u.von) / weg) * 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kommen Punkte dazu, schwebt die Zahl kurz unter dem Knopf davon.
|
||||||
|
// Steigt das Level, sprühen Funken und der Badge pulst.
|
||||||
|
const zuwachs = ref(null)
|
||||||
|
const feuerwerk = ref(false)
|
||||||
|
let vorher = null
|
||||||
|
let zuwachsTimer = null
|
||||||
|
let feuerTimer = null
|
||||||
|
|
||||||
|
watch(meins, (jetzt) => {
|
||||||
|
if (!jetzt) {
|
||||||
|
vorher = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (vorher && vorher.id === jetzt.id) {
|
||||||
|
const diff = jetzt.punkte - vorher.punkte
|
||||||
|
if (diff !== 0) {
|
||||||
|
zuwachs.value = { key: Date.now(), wert: diff }
|
||||||
|
clearTimeout(zuwachsTimer)
|
||||||
|
zuwachsTimer = setTimeout(() => (zuwachs.value = null), 1400)
|
||||||
|
}
|
||||||
|
if (jetzt.level > vorher.level) {
|
||||||
|
feuerwerk.value = true
|
||||||
|
clearTimeout(feuerTimer)
|
||||||
|
feuerTimer = setTimeout(() => (feuerwerk.value = false), 1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vorher = { id: jetzt.id, punkte: jetzt.punkte, level: jetzt.level }
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -39,10 +96,53 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="halter" class="wahl">
|
<div ref="halter" class="wahl">
|
||||||
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
|
<button
|
||||||
{{ mine ? mine.name : 'Anonym' }}
|
type="button"
|
||||||
|
class="knopf"
|
||||||
|
:class="{ offen, schlicht: !meins }"
|
||||||
|
:title="meins ? `Erfahrung: ${meins.punkte - meins.von} / ${meins.bis - meins.von}` : ''"
|
||||||
|
@click="offen = !offen"
|
||||||
|
>
|
||||||
|
<!-- Wie ein Spiel-HUD: links der Levelkreis, rechts oben der Name,
|
||||||
|
darunter die Erfahrungsleiste, die am Kreis hängt -->
|
||||||
|
<template v-if="meins">
|
||||||
|
<span class="kreis" :class="{ puls: feuerwerk }">
|
||||||
|
<svg width="34" height="34" viewBox="0 0 34 34" aria-hidden="true">
|
||||||
|
<circle cx="17" cy="17" r="16" fill="#6e1418" stroke="rgba(255, 255, 255, 0.45)" stroke-width="1" />
|
||||||
|
<text
|
||||||
|
x="17"
|
||||||
|
y="17"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
:font-size="meins.level >= 100 ? 12 : 15"
|
||||||
|
font-weight="700"
|
||||||
|
fill="#f3d9db"
|
||||||
|
text-rendering="geometricPrecision"
|
||||||
|
>
|
||||||
|
{{ meins.level }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<span v-if="feuerwerk" class="feuerwerk" aria-hidden="true">
|
||||||
|
<span v-for="i in 12" :key="i" class="funke" :style="{ '--winkel': `${i * 30}deg` }"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="rechts">
|
||||||
|
<span class="name">{{ mine.name }}</span>
|
||||||
|
<span class="leiste">
|
||||||
|
<span
|
||||||
|
class="balken"
|
||||||
|
:style="{ width: `calc(var(--kreisrand) + (100% - var(--kreisrand)) * ${anteil(meins) / 100})` }"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>Anonym</template>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span v-if="zuwachs" :key="zuwachs.key" class="zuwachs" :class="{ minus: zuwachs.wert < 0 }">
|
||||||
|
{{ zuwachs.wert > 0 ? '+' : '−' }}{{ Math.abs(zuwachs.wert) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<ul v-if="offen" class="menue">
|
<ul v-if="offen" class="menue">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
|
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
|
||||||
@@ -50,15 +150,222 @@ 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 }}
|
<span class="zeile" :title="`Erfahrung: ${u.punkte - u.von} / ${u.bis - u.von}`">
|
||||||
|
<PersonName :name="u.name" :level="u.level" />
|
||||||
|
<span v-if="u.online" class="online" title="Gerade da"></span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<FilmLogin
|
||||||
|
v-if="anmeldung"
|
||||||
|
:name="anmeldung.name"
|
||||||
|
:pruefen="pruefen"
|
||||||
|
@abbrechen="anmeldung = null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Das Level als kleine Zahl im Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.3rem;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile .badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Fortschritt zum nächsten Level, als Strich am unteren Rand */
|
||||||
|
/* Kein Kasten: Kreis und Leiste stehen frei in der Kopfzeile */
|
||||||
|
.knopf:not(.schlicht) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Levelkreis, groß und vorn — liegt über dem Anfang der Leiste */
|
||||||
|
.kreis {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
/* Farben zeichnet das SVG. Der Ring in Hintergrundfarbe hält die
|
||||||
|
Leiste einen Pixel vom Kreis weg. */
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kreis svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rechts vom Kreis: der Name oben, die Leiste darunter */
|
||||||
|
.rechts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf:hover .name,
|
||||||
|
.knopf.offen .name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Erfahrungsleiste schmiegt sich an den Kreis: sie beginnt unter
|
||||||
|
ihm, der Ring des Kreises schneidet seine Rundung hinein. Die Füllung
|
||||||
|
startet erst am Kreisrand — so ist jeder Punkt Fortschritt zu sehen. */
|
||||||
|
.leiste {
|
||||||
|
/* So weit reicht die Leiste unter den Kreis */
|
||||||
|
--unter-kreis: 0.9rem;
|
||||||
|
/* Wo der Kreisrand die Leistenmitte schneidet — ab hier zählt die
|
||||||
|
Füllung. Folgt aus Kreisradius 1.05rem und der Lage der Leiste. */
|
||||||
|
--kreisrand: 0.72rem;
|
||||||
|
position: relative;
|
||||||
|
width: calc(3.75rem + var(--unter-kreis));
|
||||||
|
height: 0.3rem;
|
||||||
|
margin-left: calc(-1 * var(--unter-kreis));
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 0 999px 999px 0;
|
||||||
|
/* Die Kreisform samt einem Pixel Luft aus Leiste und Füllung schneiden */
|
||||||
|
mask: radial-gradient(
|
||||||
|
circle at -0.15rem -0.45rem,
|
||||||
|
transparent calc(1.05rem + 2px),
|
||||||
|
#000 calc(1.05rem + 3px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Füllung beginnt ebenfalls unter dem Kreis — die Maske gibt ihr
|
||||||
|
dieselbe Rundung, sichtbar wird sie ab dem Kreisrand */
|
||||||
|
.balken {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gewonnenen Punkte sinken unter dem Knopf davon */
|
||||||
|
.zuwachs {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 41;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: schweben 1.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zuwachs.minus {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes schweben {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -4px);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 26px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level-Up: Funken aus dem Kreis, der Kreis pulst */
|
||||||
|
.feuerwerk {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 41;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funke {
|
||||||
|
position: absolute;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: rotate(var(--winkel)) translateX(0);
|
||||||
|
animation: spruehen 1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funke:nth-child(3n) {
|
||||||
|
background: #f5c518;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funke:nth-child(3n + 1) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spruehen {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(var(--winkel)) translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(var(--winkel)) translateX(48px) scale(0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kreis.puls {
|
||||||
|
animation: puls 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes puls {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
box-shadow: 0 0 12px var(--accent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.wahl {
|
.wahl {
|
||||||
position: relative;
|
position: relative;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -66,9 +373,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knopf {
|
.knopf {
|
||||||
padding: 0.32rem 0.7rem;
|
padding: 0.3rem 0.8rem 0.3rem 0.4rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
text-align: left;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -113,4 +421,28 @@ onUnmounted(() => {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Der Punkt zeigt: hier fragt die Seite nach dem Anmeldefilm */
|
||||||
|
/* Grüner Punkt: eine Seite mit diesem Namen ist gerade offen */
|
||||||
|
.online {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #3ddc84;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px), (pointer: coarse) {
|
||||||
|
.knopf {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menue button {
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
// Ein Name aus der Runde, mit dem Level als kleinem Badge davor. Das
|
||||||
|
// Level kommt aus der Runde, die App stellt sie bereit — so muss keine
|
||||||
|
// Liste durch alle Ebenen gereicht werden. Unbekannte Namen (Anonym,
|
||||||
|
// gelöscht) stehen ohne Badge.
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
level: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelVon = inject('levelVon', () => null)
|
||||||
|
const stufe = computed(() => props.level ?? levelVon(props.name))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="person">
|
||||||
|
<!-- Als SVG: die Ziffer sitzt auf der geometrischen Mitte, fest
|
||||||
|
18 px auf ganzen Pixeln -->
|
||||||
|
<svg v-if="stufe" class="lvl" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
|
||||||
|
<circle cx="9" cy="9" r="8.5" fill="#6e1418" stroke="rgba(255, 255, 255, 0.45)" stroke-width="1" />
|
||||||
|
<text
|
||||||
|
x="9"
|
||||||
|
y="9"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
:font-size="stufe >= 100 ? 7.5 : 10"
|
||||||
|
font-weight="700"
|
||||||
|
fill="#f3d9db"
|
||||||
|
text-rendering="geometricPrecision"
|
||||||
|
>
|
||||||
|
{{ stufe }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
{{ name || 'Anonym' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Badge und Name mittig auf einer Linie, egal wie groß die Schrift ist */
|
||||||
|
.person {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3em;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weinrot statt Signalrot, mit hellem Rand für den Kontrast zum Grund —
|
||||||
|
die Farben stehen im SVG selbst */
|
||||||
|
.lvl {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<script setup>
|
||||||
|
// Nach Leuten suchen: einen Namen tippen, eine Person wählen, dann
|
||||||
|
// stehen ihre Filme da — getrennt nach gespielt und Regie geführt.
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import MovieCard from './MovieCard.vue'
|
||||||
|
import { posterUrl } from '../images'
|
||||||
|
import { passtZurLeiste } from '../filter.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filter: { type: Object, default: () => ({}) },
|
||||||
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
ohneGesehene: { type: Boolean, default: true },
|
||||||
|
// Kommt aus der Filmansicht: mit dieser Person soll es losgehen
|
||||||
|
start: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['open', 'gestartet'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const personen = ref([])
|
||||||
|
const personenLaden = ref(false)
|
||||||
|
const gewaehlt = ref(null)
|
||||||
|
const rollen = ref({ gespielt: [], regie: [] })
|
||||||
|
const laden = ref(false)
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let laufende = 0
|
||||||
|
let laufendeWahl = 0
|
||||||
|
|
||||||
|
async function suchen(term) {
|
||||||
|
if (term.trim().length < 2) {
|
||||||
|
personen.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meine = ++laufende
|
||||||
|
personenLaden.value = true
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/personen?q=${encodeURIComponent(term.trim())}`)
|
||||||
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
|
const daten = await resp.json()
|
||||||
|
if (meine !== laufende) return
|
||||||
|
personen.value = daten
|
||||||
|
} catch {
|
||||||
|
if (meine === laufende) personen.value = []
|
||||||
|
} finally {
|
||||||
|
if (meine === laufende) personenLaden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => suchen(query.value), 350)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function waehlen(person) {
|
||||||
|
const meine = ++laufendeWahl
|
||||||
|
gewaehlt.value = person
|
||||||
|
query.value = ''
|
||||||
|
personen.value = []
|
||||||
|
laden.value = true
|
||||||
|
fehler.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/personen/${person.id}/filme`)
|
||||||
|
if (!resp.ok) throw new Error('Das hat nicht geklappt.')
|
||||||
|
const daten = await resp.json()
|
||||||
|
if (meine !== laufendeWahl) return
|
||||||
|
rollen.value = daten
|
||||||
|
} catch (err) {
|
||||||
|
if (meine === laufendeWahl) fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
if (meine === laufendeWahl) laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function passt(movie) {
|
||||||
|
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
|
||||||
|
return passtZurLeiste(movie, props.filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Reihenfolge aus der Leiste. "Wie auf IMDb" ist hier die von
|
||||||
|
// TMDB: die bekanntesten Filme zuerst.
|
||||||
|
const wuerfel = new Map()
|
||||||
|
function zufall(movie) {
|
||||||
|
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
|
||||||
|
return wuerfel.get(movie.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTS = {
|
||||||
|
random: (a, b) => zufall(a) - zufall(b),
|
||||||
|
rating: (a, b) => (b.vote_average || 0) - (a.vote_average || 0),
|
||||||
|
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||||
|
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||||
|
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortiert(liste) {
|
||||||
|
const sort = SORTS[props.filter.sort]
|
||||||
|
return sort ? [...liste].sort(sort) : liste
|
||||||
|
}
|
||||||
|
|
||||||
|
const abschnitte = computed(() =>
|
||||||
|
[
|
||||||
|
['Als Schauspieler', rollen.value.gespielt],
|
||||||
|
['Als Regisseur', rollen.value.regie],
|
||||||
|
]
|
||||||
|
.map(([titel, liste]) => [titel, sortiert(liste.filter(passt))])
|
||||||
|
.filter(([, liste]) => liste.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.start,
|
||||||
|
(person) => {
|
||||||
|
if (!person) return
|
||||||
|
waehlen(person)
|
||||||
|
emit('gestartet')
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function oeffnen(movie) {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok) emit('open', await resp.json())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="personen-tab">
|
||||||
|
<div class="suchzeile">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
class="big-search"
|
||||||
|
placeholder="Wen suchst du?"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tippt jemand, stehen die Leute vorn, bis einer gewählt ist -->
|
||||||
|
<template v-if="query.trim().length >= 2">
|
||||||
|
<p v-if="!personen.length && !personenLaden" class="note">Nichts gefunden.</p>
|
||||||
|
<ul v-else class="leute">
|
||||||
|
<li v-for="p in personen" :key="p.id">
|
||||||
|
<button type="button" @click="waehlen(p)">
|
||||||
|
<img v-if="p.profile_path" :src="posterUrl(p.profile_path, 'w185')" :alt="p.name" />
|
||||||
|
<span v-else class="kein-bild"></span>
|
||||||
|
<span class="text">
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
<span class="fach">{{ p.fach }}</span>
|
||||||
|
<span v-if="p.bekannt_fuer.length" class="bekannt">
|
||||||
|
{{ p.bekannt_fuer.join(' · ') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="gewaehlt">
|
||||||
|
<div class="kopf">
|
||||||
|
<img
|
||||||
|
v-if="gewaehlt.profile_path"
|
||||||
|
:src="posterUrl(gewaehlt.profile_path, 'w185')"
|
||||||
|
:alt="gewaehlt.name"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3>{{ gewaehlt.name }}</h3>
|
||||||
|
<p class="fach">{{ gewaehlt.fach }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="laden" class="note">Suche läuft…</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<p v-else-if="!abschnitte.length" class="note">Nichts gefunden. Stell die Filter weiter.</p>
|
||||||
|
|
||||||
|
<section v-for="[titel, liste] in abschnitte" :key="titel">
|
||||||
|
<p class="label">
|
||||||
|
{{ titel }}
|
||||||
|
<span class="anzahl">{{ liste.length }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="grid" :class="ansicht">
|
||||||
|
<MovieCard
|
||||||
|
v-for="film in liste"
|
||||||
|
:key="film.id"
|
||||||
|
:movie="film"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
:gesehen="gesehenIds.has(film.id)"
|
||||||
|
@open="oeffnen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.personen-tab {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suchzeile {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leeren {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leeren:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Trefferliste: Foto, Name, Fach und wofür man die Person kennt */
|
||||||
|
.leute {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leute button {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leute button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leute img,
|
||||||
|
.kein-bild {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
height: 84px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #1c1c22;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fach,
|
||||||
|
.bekannt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bekannt {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf img {
|
||||||
|
width: 64px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
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-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leute {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -156,4 +156,30 @@ const aktiv = computed(
|
|||||||
.aktiv .regler input::-moz-range-thumb {
|
.aktiv .regler input::-moz-range-thumb {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px), (pointer: coarse) {
|
||||||
|
.kopf {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler,
|
||||||
|
.regler input {
|
||||||
|
height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-webkit-slider-runnable-track {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-webkit-slider-thumb {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-top: -0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regler input::-moz-range-thumb {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<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'
|
||||||
|
import PersonName from './PersonName.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)"
|
||||||
|
>
|
||||||
|
<PersonName :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>
|
||||||
@@ -7,6 +7,8 @@ const props = defineProps({
|
|||||||
// Was die Filterleiste eingestellt hat
|
// Was die Filterleiste eingestellt hat
|
||||||
filter: { type: Object, default: () => ({}) },
|
filter: { type: Object, default: () => ({}) },
|
||||||
ansicht: { type: String, default: 'kompakt' },
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
ohneGesehene: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open'])
|
const emit = defineEmits(['open'])
|
||||||
@@ -43,6 +45,11 @@ function passt(movie) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schon gelaufene Filme fallen weg, solange der Schalter in der Leiste steht
|
||||||
|
function nichtGesehen(movie) {
|
||||||
|
return !props.ohneGesehene || !props.gesehenIds.has(movie.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function run(term) {
|
async function run(term) {
|
||||||
const meine = ++laufende
|
const meine = ++laufende
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -51,13 +58,13 @@ async function run(term) {
|
|||||||
try {
|
try {
|
||||||
const url = stoebern
|
const url = stoebern
|
||||||
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
|
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
|
||||||
: `/api/search?q=${encodeURIComponent(term.trim())}`
|
: `/api/search?q=${encodeURIComponent(term.trim())}&limit=${props.filter.anzahl || 24}`
|
||||||
const resp = await fetch(url)
|
const resp = await fetch(url)
|
||||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||||
if (meine !== laufende) return
|
if (meine !== laufende) return
|
||||||
results.value = stoebern ? data : data.filter(passt)
|
results.value = (stoebern ? data : data.filter(passt)).filter(nichtGesehen)
|
||||||
gestoebert.value = stoebern
|
gestoebert.value = stoebern
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (meine === laufende) error.value = err.message
|
if (meine === laufende) error.value = err.message
|
||||||
@@ -84,6 +91,7 @@ function leeren() {
|
|||||||
|
|
||||||
watch(query, spaeterSuchen)
|
watch(query, spaeterSuchen)
|
||||||
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
|
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
|
||||||
|
watch(() => props.ohneGesehene, spaeterSuchen)
|
||||||
|
|
||||||
async function open(movie) {
|
async function open(movie) {
|
||||||
const resp = await fetch(`/api/search/${movie.id}`)
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
@@ -105,15 +113,15 @@ async function open(movie) {
|
|||||||
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
|
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
|
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">Suchen</button>
|
||||||
Suchen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loading" class="note">Suche läuft…</p>
|
<p v-if="loading" class="note">Suche läuft…</p>
|
||||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||||
<p v-else-if="!results.length" class="note">
|
<p v-else-if="!results.length" class="note">
|
||||||
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
|
{{
|
||||||
|
gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.`
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid" :class="ansicht">
|
<div class="grid" :class="ansicht">
|
||||||
@@ -122,6 +130,7 @@ async function open(movie) {
|
|||||||
:key="movie.id"
|
:key="movie.id"
|
||||||
:movie="movie"
|
:movie="movie"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
|
:gesehen="gesehenIds.has(movie.id)"
|
||||||
@open="open"
|
@open="open"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +151,9 @@ async function open(movie) {
|
|||||||
|
|
||||||
.big-search {
|
.big-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus
|
||||||
|
dem Bild, wenn der Schirm schmal ist */
|
||||||
|
min-width: 0;
|
||||||
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
@@ -161,7 +173,10 @@ async function open(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.neu {
|
.neu {
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0 1.4rem;
|
padding: 0 1.4rem;
|
||||||
|
/* Der Leeren-Knopf im Feld richtet sich nach dieser Breite */
|
||||||
|
width: 7.2rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
@@ -180,10 +195,19 @@ async function open(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
}
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const props = defineProps({
|
|||||||
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
|
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
|
||||||
start: { type: Number, default: 0 },
|
start: { type: Number, default: 0 },
|
||||||
aufDemRad: { type: Array, default: () => [] },
|
aufDemRad: { type: Array, default: () => [] },
|
||||||
|
// Kennungen der schon gelaufenen Filme
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
ansicht: { type: String, default: 'kompakt' },
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ const rating = computed(() =>
|
|||||||
|
|
||||||
const stehtAufDemRad = computed(() => props.aufDemRad.includes(current.value.id))
|
const stehtAufDemRad = computed(() => props.aufDemRad.includes(current.value.id))
|
||||||
|
|
||||||
|
const schonGesehen = computed(() => props.gesehenIds.has(current.value.id))
|
||||||
|
|
||||||
const shots = computed(() =>
|
const shots = computed(() =>
|
||||||
(current.value.images || []).slice(0, 4).map((pfad) => backdropUrl(pfad, 'w300'))
|
(current.value.images || []).slice(0, 4).map((pfad) => backdropUrl(pfad, 'w300'))
|
||||||
)
|
)
|
||||||
@@ -38,7 +42,7 @@ function step(delta) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="card" :class="[ansicht, { 'auf-dem-rad': stehtAufDemRad }]">
|
<article class="card" :class="[ansicht, { 'auf-dem-rad': stehtAufDemRad, gesehen: schonGesehen }]">
|
||||||
<span v-if="stehtAufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
|
<span v-if="stehtAufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
|
||||||
<div class="haupt">
|
<div class="haupt">
|
||||||
<button class="poster" type="button" @click="$emit('open', current)">
|
<button class="poster" type="button" @click="$emit('open', current)">
|
||||||
@@ -91,6 +95,18 @@ function step(delta) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Schon gelaufen — die Karte tritt zurück, bleibt aber lesbar.
|
||||||
|
Beim Überfahren kommt sie zurück. */
|
||||||
|
.card.gesehen {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.gesehen:hover {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -344,4 +360,53 @@ h2:hover {
|
|||||||
.card.details .shots {
|
.card.details .shots {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schmal und kompakt: nur Poster, Titel und Jahr */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.card.kompakt .body {
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt h2 {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
min-height: calc(2 * 1.25em);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt .meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.kompakt .teaser {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.card.details {
|
||||||
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.details .poster {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.details .shots {
|
||||||
|
grid-area: 2 / 1 / 3 / 3;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.details .body {
|
||||||
|
grid-area: 1 / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,9 +2,14 @@
|
|||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
// Alle Zuschauer regeln auf dieselbe Distanz zur Live-Kante. Damit laufen
|
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
|
||||||
// sie synchron, ohne dass ein Server sie taktet. hls.js passt dafür die
|
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
|
||||||
// Abspielgeschwindigkeit an (maxLiveSyncPlaybackRate).
|
// Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate.
|
||||||
|
const props = defineProps({
|
||||||
|
// Marken setzen darf nur, wer einen Namen hat
|
||||||
|
angemeldet: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
const ZIEL_LATENZ = 4
|
const ZIEL_LATENZ = 4
|
||||||
|
|
||||||
const video = ref(null)
|
const video = ref(null)
|
||||||
@@ -86,6 +91,31 @@ function tasteAbfangen(e) {
|
|||||||
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
|
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eine Marke setzen: der Moment wird gemerkt, den Clip schneidet man
|
||||||
|
// später im Clips-Reiter. Taste C, damit nichts vom Film ablenkt.
|
||||||
|
const markeStand = ref('')
|
||||||
|
let markeTimer = null
|
||||||
|
|
||||||
|
async function markieren() {
|
||||||
|
if (!props.angemeldet || markeStand.value === 'läuft') return
|
||||||
|
markeStand.value = 'läuft'
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/clips', { method: 'POST' })
|
||||||
|
markeStand.value = resp.ok ? 'gesetzt' : 'fehler'
|
||||||
|
} catch {
|
||||||
|
markeStand.value = 'fehler'
|
||||||
|
}
|
||||||
|
clearTimeout(markeTimer)
|
||||||
|
markeTimer = setTimeout(() => (markeStand.value = ''), 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasteC(e) {
|
||||||
|
if (e.key !== 'c' && e.key !== 'C') return
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return
|
||||||
|
if (e.target instanceof Element && e.target.matches('input, textarea')) return
|
||||||
|
markieren()
|
||||||
|
}
|
||||||
|
|
||||||
function verbinden() {
|
function verbinden() {
|
||||||
const quelle = '/stream/filmabend/index.m3u8'
|
const quelle = '/stream/filmabend/index.m3u8'
|
||||||
|
|
||||||
@@ -108,11 +138,13 @@ function verbinden() {
|
|||||||
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
|
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
|
||||||
// führt laut Doku zu Stockern.
|
// führt laut Doku zu Stockern.
|
||||||
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
|
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
|
||||||
// Über 1 erlaubt hls.js, die Geschwindigkeit anzuheben, um die
|
// Kein Aufholen über die Geschwindigkeit. Mit 1,1 wechselte der Player
|
||||||
// Ziel-Latenz zu halten. 1,5 wäre deutlich hörbar, wenn der Player sie
|
// gemessene 112-mal in 104 Sekunden das Tempo und lief 37 % der Zeit
|
||||||
// ausreizt; 1,1 reicht für normale Schwankungen und fällt nicht auf.
|
// schneller als normal — jeder Wechsel ist ein Übergang im Zeitdehner
|
||||||
// Bleibt der Rückstand größer, springt liveMaxLatencyDuration ohnehin.
|
// und knackst hörbar. Der Puffer lief dabei nie leer (nie unter 2 s),
|
||||||
maxLiveSyncPlaybackRate: 1.1,
|
// die Regelung war also gar nicht nötig. Bleibt der Rückstand doch
|
||||||
|
// einmal groß, springt liveMaxLatencyDuration.
|
||||||
|
maxLiveSyncPlaybackRate: 1,
|
||||||
backBufferLength: 30,
|
backBufferLength: 30,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,6 +193,7 @@ function vollbildGewechselt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', tasteC)
|
||||||
verbinden()
|
verbinden()
|
||||||
wacheStarten()
|
wacheStarten()
|
||||||
document.addEventListener('fullscreenchange', vollbildGewechselt)
|
document.addEventListener('fullscreenchange', vollbildGewechselt)
|
||||||
@@ -168,6 +201,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', tasteC)
|
||||||
document.removeEventListener('fullscreenchange', vollbildGewechselt)
|
document.removeEventListener('fullscreenchange', vollbildGewechselt)
|
||||||
window.removeEventListener('keydown', tasteAbfangen)
|
window.removeEventListener('keydown', tasteAbfangen)
|
||||||
window.clearInterval(wache)
|
window.clearInterval(wache)
|
||||||
@@ -212,6 +246,16 @@ onUnmounted(() => {
|
|||||||
@input="lautstaerkeSetzen(Number($event.target.value))"
|
@input="lautstaerkeSetzen(Number($event.target.value))"
|
||||||
/>
|
/>
|
||||||
<span class="live">live</span>
|
<span class="live">live</span>
|
||||||
|
<button
|
||||||
|
v-if="angemeldet"
|
||||||
|
type="button"
|
||||||
|
class="knopf clip"
|
||||||
|
:class="{ an: markeStand === 'gesetzt' }"
|
||||||
|
title="Marke setzen (Taste C)"
|
||||||
|
@click="markieren"
|
||||||
|
>
|
||||||
|
{{ markeStand === 'gesetzt' ? '✓ Marke' : markeStand === 'fehler' ? 'Fehler' : 'Clip' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="knopf"
|
class="knopf"
|
||||||
@@ -255,6 +299,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.rahmen {
|
.rahmen {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
|
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
|
||||||
max-height: calc(100vh - 5.5rem);
|
max-height: calc(100vh - 5.5rem);
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
@@ -296,6 +341,25 @@ onUnmounted(() => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.leiste {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf.clip {
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf.clip.an {
|
||||||
|
color: #6bbf7a;
|
||||||
|
border-color: #6bbf7a;
|
||||||
|
}
|
||||||
|
|
||||||
.knopf {
|
.knopf {
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|||||||
@@ -1,13 +1,140 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import FilmSuche from './FilmSuche.vue'
|
||||||
|
import Einladung from './Einladung.vue'
|
||||||
|
import { posterUrl } from '../images'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Einladungskarte — ein eigenes Fenster
|
||||||
|
const einladung = ref(false)
|
||||||
|
|
||||||
|
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
|
||||||
|
const hostFilmDialog = ref(false)
|
||||||
|
const hostFilm = ref(null)
|
||||||
|
|
||||||
|
async function hostFilmZeigen() {
|
||||||
|
if (hostFilm.value) {
|
||||||
|
hostFilm.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostFilm.value = { laden: true }
|
||||||
|
const resp = await fetch('/api/host/film')
|
||||||
|
hostFilm.value = resp.ok ? await resp.json() : { fehler: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostFilmSetzen(movieId) {
|
||||||
|
await fetch('/api/host/film', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ movie_id: movieId }),
|
||||||
|
})
|
||||||
|
hostFilm.value = null
|
||||||
|
hostFilmDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Aufnahmen der Abende — der Host löscht, was weg kann. Erst ein
|
||||||
|
// zweiter Klick auf denselben Knopf löscht wirklich.
|
||||||
|
const aufnahmen = ref(null)
|
||||||
|
const aufnahmenFehler = ref('')
|
||||||
|
const loeschFrage = ref(null)
|
||||||
|
|
||||||
|
async function aufnahmenLaden() {
|
||||||
|
if (!props.host) return
|
||||||
|
loeschFrage.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/aufnahmen')
|
||||||
|
if (!resp.ok) throw new Error()
|
||||||
|
aufnahmen.value = (await resp.json()).stuecke
|
||||||
|
} catch {
|
||||||
|
aufnahmen.value = []
|
||||||
|
aufnahmenFehler.value = 'Die Aufnahmen sind gerade nicht erreichbar.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aufnahmeLoeschen(s) {
|
||||||
|
if (loeschFrage.value !== s.start) {
|
||||||
|
loeschFrage.value = s.start
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aufnahmenFehler.value = ''
|
||||||
|
const params = new URLSearchParams({ start: s.start, dauer: s.dauer })
|
||||||
|
const resp = await fetch(`/api/aufnahmen?${params}`, { method: 'DELETE' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
// Die Meldung vom Server sagt, woran es lag — sie bleibt stehen
|
||||||
|
const daten = await resp.json().catch(() => ({}))
|
||||||
|
aufnahmenFehler.value = daten.detail || 'Löschen hat nicht geklappt.'
|
||||||
|
}
|
||||||
|
await aufnahmenLaden()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welcher Film in einer Aufnahme lief, sagt der Host — per Filmsuche
|
||||||
|
const filmDialogFuer = ref(null)
|
||||||
|
|
||||||
|
async function filmZuweisen(start, movieId) {
|
||||||
|
aufnahmenFehler.value = ''
|
||||||
|
const resp = await fetch('/api/aufnahmen/film', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ start, movie_id: movieId }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) aufnahmenFehler.value = 'Zuweisen hat nicht geklappt.'
|
||||||
|
filmDialogFuer.value = null
|
||||||
|
await aufnahmenLaden()
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufnahmeText(s) {
|
||||||
|
const d = new Date(s.start)
|
||||||
|
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const zeit = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${datum} · ${zeit} Uhr · ${Math.max(1, Math.round(s.dauer / 60))} Min`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(aufnahmenLaden)
|
||||||
|
watch(() => props.host, aufnahmenLaden)
|
||||||
|
|
||||||
async function anlegen() {
|
async function anlegen() {
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
const resp = await fetch('/api/users', {
|
const resp = await fetch('/api/users', {
|
||||||
@@ -27,9 +154,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 +173,218 @@ 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"><PersonName :name="u.name" :level="u.level" /></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>
|
||||||
|
|
||||||
|
<div v-if="host" class="rechts">
|
||||||
|
<section class="einladung">
|
||||||
|
<h3>Einladung</h3>
|
||||||
|
<p class="erklaerung">
|
||||||
|
Eine Karte zum Verschicken: Datum, Genre, Discord, Webseite. Als Bild fürs Handy.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="klein" @click="einladung = true">Einladung erstellen</button>
|
||||||
|
</section>
|
||||||
|
<Einladung v-if="einladung" @zu="einladung = false" />
|
||||||
|
|
||||||
|
<section class="hostfilm">
|
||||||
|
<h3>Host-Film</h3>
|
||||||
|
<p class="erklaerung">
|
||||||
|
Die Tastenkombination fragt ohne Freischaltung nach diesem Film. Wer ihn anklickt, ist
|
||||||
|
Host — ohne Link.
|
||||||
|
</p>
|
||||||
|
<div class="zeile">
|
||||||
|
<button type="button" class="klein" @click="hostFilmDialog = true">Film setzen</button>
|
||||||
|
<button type="button" class="klein" title="Anzeigen" @click="hostFilmZeigen">👁</button>
|
||||||
|
<button type="button" class="klein" @click="hostFilmSetzen(null)">Aufheben</button>
|
||||||
|
<span v-if="hostFilm" class="film-anzeige">
|
||||||
|
<template v-if="hostFilm.laden">…</template>
|
||||||
|
<template v-else-if="hostFilm.fehler">nicht lesbar</template>
|
||||||
|
<template v-else-if="hostFilm.film"
|
||||||
|
>{{ hostFilm.film.title }} ({{ hostFilm.film.year }})</template
|
||||||
|
>
|
||||||
|
<template v-else>keiner gesetzt</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="aufnahmen">
|
||||||
|
<h3>Aufnahmen</h3>
|
||||||
|
<p v-if="aufnahmenFehler" class="fehler">{{ aufnahmenFehler }}</p>
|
||||||
|
<p v-if="aufnahmen === null" class="leer">Suche Aufnahmen …</p>
|
||||||
|
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
|
||||||
|
<span>
|
||||||
|
{{ aufnahmeText(s) }} ·
|
||||||
|
<strong v-if="s.film">{{ s.film.title }}</strong>
|
||||||
|
<em v-else>kein Film</em>
|
||||||
|
</span>
|
||||||
|
<span class="knoepfe">
|
||||||
|
<button type="button" class="klein" @click="filmDialogFuer = s.start">Film</button>
|
||||||
|
<button
|
||||||
|
v-if="s.zugewiesen"
|
||||||
|
type="button"
|
||||||
|
class="klein"
|
||||||
|
@click="filmZuweisen(s.start, null)"
|
||||||
|
>
|
||||||
|
Aufheben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="klein"
|
||||||
|
:class="{ warnung: loeschFrage === s.start }"
|
||||||
|
@click="aufnahmeLoeschen(s)"
|
||||||
|
>
|
||||||
|
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="filmDialogFuer" class="hintergrund" @click.self="filmDialogFuer = null">
|
||||||
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
|
<p class="label">Aufnahme</p>
|
||||||
|
<h2>Welcher Film lief?</h2>
|
||||||
|
<FilmSuche
|
||||||
|
platzhalter="Film suchen …"
|
||||||
|
@waehlen="filmZuweisen(filmDialogFuer, $event.id)"
|
||||||
|
/>
|
||||||
|
<div class="fuss">
|
||||||
|
<button type="button" class="abbrechen" @click="filmDialogFuer = null">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
|
||||||
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
|
<p class="label">Host-Film</p>
|
||||||
|
<h2>Passwort für den Hostmodus</h2>
|
||||||
|
<p class="frage">Diesen Film klickt man nach der Tastenkombination an.</p>
|
||||||
|
<FilmSuche platzhalter="Film als Passwort …" @waehlen="hostFilmSetzen($event.id)" />
|
||||||
|
<div class="fuss">
|
||||||
|
<button type="button" class="abbrechen" @click="hostFilmDialog = false">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<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;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 2.5rem 3.5rem;
|
||||||
|
align-items: start;
|
||||||
|
max-width: 72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mit Host-Rechten: Benutzer links, der Rest rechts */
|
||||||
|
.verwaltung-tab:has(.rechts) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rechts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.verwaltung-tab:has(.rechts) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -99,20 +422,73 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.liste {
|
.liste {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste li {
|
.liste li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste li + li {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.55rem 0.85rem;
|
}
|
||||||
background: var(--bg-soft);
|
|
||||||
|
/* 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: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
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 {
|
||||||
@@ -132,13 +508,161 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leer,
|
.leer,
|
||||||
.fehler {
|
.fehler,
|
||||||
|
.erklaerung {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.erklaerung {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen .zeile {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen .knoepfe {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen em {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klein.warnung {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hostfilm .zeile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.film-anzeige {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { posterUrl } from '../images'
|
||||||
|
import { FARBEN } from '../composables/useZiehung'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
laden: Boolean,
|
||||||
|
host: Boolean,
|
||||||
|
// Der Würfel zieht aus der eigenen Sammlung — die gibt es nur mit Namen
|
||||||
|
angemeldet: Boolean,
|
||||||
|
merkliste: { type: Array, default: () => [] },
|
||||||
|
mine: { type: Number, default: null },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
// Wer ohne Vorschlag dabei ist, und ob ich das bin
|
||||||
|
dabei: { type: Array, default: () => [] },
|
||||||
|
dabeiMine: Boolean,
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['open', 'fehler'])
|
||||||
|
|
||||||
|
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
||||||
|
// anderen — für Abende, an denen es keiner gruselig mag.
|
||||||
|
const nurHorror = ref(true)
|
||||||
|
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
|
||||||
|
const wuerfelt = ref(false)
|
||||||
|
|
||||||
|
function nachGenre(horror) {
|
||||||
|
return sammlung.value.filter(
|
||||||
|
(e) => (e.genres || []).includes('Horror') === horror
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const auswahl = computed(() => nachGenre(nurHorror.value))
|
||||||
|
const anzahlHorror = computed(() => nachGenre(true).length)
|
||||||
|
const anzahlOhne = computed(() => nachGenre(false).length)
|
||||||
|
|
||||||
|
// Dabei sein zählt auch ohne Film — für die Anwesenheit beim Dreh
|
||||||
|
async function dabeiUmschalten() {
|
||||||
|
await fetch('/api/dabei', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wuerfeln() {
|
||||||
|
const treffer = auswahl.value
|
||||||
|
if (!treffer.length || wuerfelt.value) return
|
||||||
|
wuerfelt.value = true
|
||||||
|
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ movie_id: film.movie_id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error()
|
||||||
|
} catch {
|
||||||
|
emit('fehler', 'Eintragen hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
wuerfelt.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="liste">
|
||||||
|
<div v-if="!host && angemeldet" class="sammlung-wuerfeln">
|
||||||
|
<div class="schalter">
|
||||||
|
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
||||||
|
Horror ({{ anzahlHorror }})
|
||||||
|
</button>
|
||||||
|
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
||||||
|
Kein Horror ({{ anzahlOhne }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wuerfeln"
|
||||||
|
:disabled="!auswahl.length || wuerfelt"
|
||||||
|
@click="wuerfeln"
|
||||||
|
>
|
||||||
|
Zufällig aus Sammlung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="angemeldet && mine === null"
|
||||||
|
type="button"
|
||||||
|
class="dabei-knopf"
|
||||||
|
:class="{ an: dabeiMine }"
|
||||||
|
@click="dabeiUmschalten"
|
||||||
|
>
|
||||||
|
{{ dabeiMine ? 'Ich bin dabei ✓' : 'Dabei ohne Vorschlag' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||||
|
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(item, i) in items" :key="item.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="eintrag"
|
||||||
|
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
|
||||||
|
@click="emit('open', item)"
|
||||||
|
>
|
||||||
|
<span class="cover" :style="{ borderColor: FARBEN[i % FARBEN.length] }">
|
||||||
|
<img
|
||||||
|
v-if="posterUrl(item.poster_path, 'w154')"
|
||||||
|
:src="posterUrl(item.poster_path, 'w154')"
|
||||||
|
:alt="item.title"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="daten">
|
||||||
|
<span class="titel">{{ item.title }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="item.genres?.length" class="genres">
|
||||||
|
{{ item.genres.join(' · ') }}
|
||||||
|
</span>
|
||||||
|
<span class="von"><PersonName :name="item.name" /></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-if="dabei.length" class="dabei">
|
||||||
|
<span class="dabei-titel">Dabei ohne Vorschlag</span>
|
||||||
|
<span class="dabei-namen">
|
||||||
|
<PersonName v-for="n in dabei" :key="n" :name="n" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dabei-knopf {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei-knopf:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei-knopf.an {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: #6bbf7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei-namen {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dabei-titel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sammlung-wuerfeln {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter button.an {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuerfeln {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuerfeln:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuerfeln:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leer {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anzahl {
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
||||||
|
.eintrag.gesehen {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag.gesehen:hover {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag:hover {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
|
||||||
|
.cover {
|
||||||
|
display: block;
|
||||||
|
width: 46px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-left: 3px solid;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daten {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titel {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eintrag.eigen .titel {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta,
|
||||||
|
.genres,
|
||||||
|
.von {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.von {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,29 +1,221 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
// Das Buch der Abende. Jede Seite ein Film, die Seiten liegen nebeneinander.
|
||||||
import { posterUrl } from '../images'
|
// Ziehen blättert frei und rollt aus, ein Klick auf eine Seite holt sie in
|
||||||
|
// die Mitte, die Pfeiltasten gehen Seite für Seite. Kein Einrasten durch
|
||||||
|
// den Browser — das käme der eigenen Bewegung in die Quere. Der jüngste
|
||||||
|
// Abend liegt ganz links und ist beim Öffnen aufgeschlagen.
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { abonnieren } from '../live'
|
import { abonnieren } from '../live'
|
||||||
|
import Abend from './Abend.vue'
|
||||||
|
|
||||||
const props = defineProps({ host: Boolean })
|
const props = defineProps({
|
||||||
|
host: Boolean,
|
||||||
|
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
|
||||||
|
angemeldet: Boolean,
|
||||||
|
runde: { type: Array, default: () => [] },
|
||||||
|
// Wer gerade gewählt ist — wechselt der Name, gehören die "Du:"-Sterne
|
||||||
|
// einer anderen Person und müssen neu geladen werden
|
||||||
|
ich: { type: Object, default: null },
|
||||||
|
})
|
||||||
const emit = defineEmits(['open', 'changed'])
|
const emit = defineEmits(['open', 'changed'])
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
|
|
||||||
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
|
|
||||||
// zurückholen kann
|
|
||||||
const sichtbar = computed(() =>
|
|
||||||
props.host ? items.value : items.value.filter((e) => !e.hidden)
|
|
||||||
)
|
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
const laden = ref(true)
|
const laden = ref(true)
|
||||||
|
|
||||||
const STERNE = [1, 2, 3, 4, 5]
|
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
|
||||||
|
// zurückholen kann. Die Liste kommt jüngster zuerst — so liegt er links.
|
||||||
|
const seiten = computed(() => (props.host ? items.value : items.value.filter((e) => !e.hidden)))
|
||||||
|
|
||||||
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
|
const buch = ref(null)
|
||||||
const schwebt = ref({ id: null, wert: 0 })
|
const aktuell = ref(0)
|
||||||
|
|
||||||
function gefuellt(item, stern) {
|
function seitenbreite() {
|
||||||
if (schwebt.value.id === item.id) return stern <= schwebt.value.wert
|
return buch.value?.querySelector('.blatt')?.offsetWidth || 1
|
||||||
return item.mine >= stern
|
}
|
||||||
|
|
||||||
|
// Welche Seite liegt gerade in der Mitte — für Anzeige und Tastatur
|
||||||
|
function beimBlaettern() {
|
||||||
|
if (!buch.value) return
|
||||||
|
aktuell.value = Math.round(buch.value.scrollLeft / seitenbreite())
|
||||||
|
}
|
||||||
|
|
||||||
|
function blaettern(schritt) {
|
||||||
|
// Die Seite direkt aus der Position lesen — der Zähler hinkt beim
|
||||||
|
// Ausrollen einen Tick hinterher
|
||||||
|
const jetzt = Math.round((buch.value?.scrollLeft || 0) / seitenbreite())
|
||||||
|
const ziel = Math.max(0, Math.min(seiten.value.length - 1, jetzt + schritt))
|
||||||
|
gleiten(ziel * seitenbreite())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bewegung ───────────────────────────────────────────────────────────
|
||||||
|
let animation = 0
|
||||||
|
|
||||||
|
function anhalten() {
|
||||||
|
cancelAnimationFrame(animation)
|
||||||
|
animation = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gleitet zu einer Zielposition: schnell los, sanft ankommen
|
||||||
|
function gleiten(ziel) {
|
||||||
|
const el = buch.value
|
||||||
|
if (!el) return
|
||||||
|
anhalten()
|
||||||
|
const start = el.scrollLeft
|
||||||
|
const weg = ziel - start
|
||||||
|
if (Math.abs(weg) < 1) return
|
||||||
|
const dauer = Math.min(600, Math.max(250, Math.abs(weg) * 0.6))
|
||||||
|
const beginn = performance.now()
|
||||||
|
|
||||||
|
function schritt(jetzt) {
|
||||||
|
const t = Math.min(1, (jetzt - beginn) / dauer)
|
||||||
|
const p = 1 - (1 - t) ** 3
|
||||||
|
el.scrollLeft = start + weg * p
|
||||||
|
if (t < 1) animation = requestAnimationFrame(schritt)
|
||||||
|
else animation = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
animation = requestAnimationFrame(schritt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollt mit der Geschwindigkeit der Hand aus, mit Reibung, und bleibt
|
||||||
|
// liegen, wo es steht — wie ein Blatt, das man anschubst
|
||||||
|
function ausrollen(tempo) {
|
||||||
|
const el = buch.value
|
||||||
|
if (!el || Math.abs(tempo) < 0.05) return
|
||||||
|
anhalten()
|
||||||
|
// Nach links gezogen heißt: die Bahn läuft weiter nach rechts
|
||||||
|
let v = -tempo
|
||||||
|
let vorher = performance.now()
|
||||||
|
const ende = el.scrollWidth - el.clientWidth
|
||||||
|
|
||||||
|
function schritt(jetzt) {
|
||||||
|
const dt = Math.min(50, jetzt - vorher)
|
||||||
|
vorher = jetzt
|
||||||
|
el.scrollLeft = Math.max(0, Math.min(ende, el.scrollLeft + v * dt))
|
||||||
|
// Reibung je Millisekunde — nach etwa einer Sekunde steht es
|
||||||
|
v *= 0.994 ** dt
|
||||||
|
const amRand = el.scrollLeft <= 0 || el.scrollLeft >= ende
|
||||||
|
if (Math.abs(v) > 0.02 && !amRand) animation = requestAnimationFrame(schritt)
|
||||||
|
else animation = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
animation = requestAnimationFrame(schritt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim ersten Laden ganz nach links: dort liegt der jüngste Abend
|
||||||
|
let aufgeschlagen = false
|
||||||
|
|
||||||
|
watch(seiten, async (liste) => {
|
||||||
|
if (aufgeschlagen || !liste.length) return
|
||||||
|
aufgeschlagen = true
|
||||||
|
await nextTick()
|
||||||
|
if (buch.value) buch.value.scrollLeft = 0
|
||||||
|
beimBlaettern()
|
||||||
|
})
|
||||||
|
|
||||||
|
function tastatur(e) {
|
||||||
|
if (e.target instanceof Element && e.target.matches('input, textarea')) return
|
||||||
|
if (e.key === 'ArrowLeft') blaettern(-1)
|
||||||
|
else if (e.key === 'ArrowRight') blaettern(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit der Maus ziehen wie mit dem Finger. Die Bahn folgt der Hand eins
|
||||||
|
// zu eins und rollt beim Loslassen aus.
|
||||||
|
let zieht = false
|
||||||
|
let startX = 0
|
||||||
|
let startScroll = 0
|
||||||
|
let gezogen = false
|
||||||
|
// Die letzten Bewegungen, um beim Loslassen das Tempo zu kennen
|
||||||
|
let spur = []
|
||||||
|
|
||||||
|
function ziehenStart(e) {
|
||||||
|
// Finger und Stift macht der Browser selbst, und Knöpfe oder Felder
|
||||||
|
// sollen ihren Klick behalten
|
||||||
|
if (e.pointerType !== 'mouse' || e.button !== 0) return
|
||||||
|
if (e.target instanceof Element && e.target.closest('input, textarea, button, a')) return
|
||||||
|
const el = buch.value
|
||||||
|
anhalten()
|
||||||
|
el.style.userSelect = 'none'
|
||||||
|
el.style.cursor = 'grabbing'
|
||||||
|
startX = e.clientX
|
||||||
|
startScroll = el.scrollLeft
|
||||||
|
gezogen = false
|
||||||
|
zieht = true
|
||||||
|
spur = [[e.clientX, performance.now()]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ziehen(e) {
|
||||||
|
if (!zieht) return
|
||||||
|
// Wurde die Taste außerhalb des Buchs losgelassen, ist der Zug vorbei
|
||||||
|
if (!(e.buttons & 1)) return ziehenEnde(e)
|
||||||
|
const dx = e.clientX - startX
|
||||||
|
// Erst ab ein paar Pixeln zählt es als Ziehen, nicht als Klick
|
||||||
|
if (!gezogen && Math.abs(dx) < 5) return
|
||||||
|
if (!gezogen) {
|
||||||
|
gezogen = true
|
||||||
|
// Erst jetzt die Maus festhalten, damit der Zug auch außerhalb des
|
||||||
|
// Buchs weitergeht. Beim Drücken wäre es zu früh: mit gehaltenem
|
||||||
|
// Zeiger käme der Klick beim Buch an statt bei der Seite.
|
||||||
|
try {
|
||||||
|
buch.value.setPointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* ohne geht es auch, solange die Maus im Buch bleibt */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buch.value.scrollLeft = startScroll - dx
|
||||||
|
spur.push([e.clientX, performance.now()])
|
||||||
|
// Nur die letzten 100 ms zählen fürs Tempo
|
||||||
|
const grenze = performance.now() - 100
|
||||||
|
while (spur.length > 2 && spur[0][1] < grenze) spur.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ziehenEnde(e) {
|
||||||
|
if (!zieht) return
|
||||||
|
zieht = false
|
||||||
|
const el = buch.value
|
||||||
|
try {
|
||||||
|
el.releasePointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* war nicht gehalten */
|
||||||
|
}
|
||||||
|
el.style.userSelect = ''
|
||||||
|
el.style.cursor = ''
|
||||||
|
if (!gezogen) return
|
||||||
|
// Der Klick, der den Zug beendet, kommt sofort nach dem Loslassen und
|
||||||
|
// wird abgefangen. Danach darf der nächste wieder durch — auch wenn
|
||||||
|
// der Zug ohne Klick endete, etwa weil das Fenster den Fokus verlor.
|
||||||
|
setTimeout(() => {
|
||||||
|
gezogen = false
|
||||||
|
}, 0)
|
||||||
|
// Tempo in Pixel je Millisekunde aus den letzten Bewegungen
|
||||||
|
const [x0, t0] = spur[0]
|
||||||
|
const [x1, t1] = spur[spur.length - 1]
|
||||||
|
// Steht die Hand beim Loslassen schon, rollt nichts nach
|
||||||
|
const stand = performance.now() - t1 > 80
|
||||||
|
ausrollen(stand || t1 <= t0 ? 0 : (x1 - x0) / (t1 - t0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Klick auf eine Seite, die nicht in der Mitte liegt, holt sie hinein.
|
||||||
|
// Knöpfe, Felder und der Titel behalten ihren eigenen Klick.
|
||||||
|
function seiteWaehlen(e) {
|
||||||
|
if (gezogen) return
|
||||||
|
if (e.target instanceof Element && e.target.closest('input, textarea, button, a, h2')) return
|
||||||
|
const blatt = e.target instanceof Element ? e.target.closest('.blatt') : null
|
||||||
|
if (!blatt) return
|
||||||
|
const index = [...buch.value.querySelectorAll('.blatt')].indexOf(blatt)
|
||||||
|
const ziel = index * seitenbreite()
|
||||||
|
if (Math.abs(buch.value.scrollLeft - ziel) > 4) gleiten(ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einem Zug darf der Klick nicht mehr durchgehen, sonst öffnet er,
|
||||||
|
// worauf die Maus beim Loslassen stand
|
||||||
|
function klickAbfangen(e) {
|
||||||
|
if (gezogen) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
gezogen = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let abbestellen = null
|
let abbestellen = null
|
||||||
@@ -40,67 +232,14 @@ async function laden_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bewerten(item, sterne) {
|
watch(
|
||||||
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen
|
() => props.ich?.id,
|
||||||
item.mine = sterne
|
() => laden_(),
|
||||||
try {
|
)
|
||||||
await fetch(`/api/watched/${item.id}/rating`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ stars: sterne }),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
fehler.value = 'Bewerten hat nicht geklappt.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sichtbarkeit(item) {
|
|
||||||
await fetch(`/api/watched/${item.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ hidden: !item.hidden }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wertungEntfernen(wertung) {
|
|
||||||
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function entfernen(item) {
|
|
||||||
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function oeffnen(item) {
|
|
||||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
|
||||||
if (resp.ok) emit('open', await resp.json())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Für das Datumsfeld zählt der lokale Tag, nicht der in UTC
|
|
||||||
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(item, tag) {
|
|
||||||
if (!tag) return
|
|
||||||
await fetch(`/api/watched/${item.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ watched_at: tag }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function datum(iso) {
|
|
||||||
return new Date(iso).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
laden_()
|
laden_()
|
||||||
|
window.addEventListener('keydown', tastatur)
|
||||||
abbestellen = abonnieren((nachricht) => {
|
abbestellen = abonnieren((nachricht) => {
|
||||||
if (nachricht.type !== 'watched') return
|
if (nachricht.type !== 'watched') return
|
||||||
laden_()
|
laden_()
|
||||||
@@ -108,306 +247,140 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => abbestellen?.())
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', tastatur)
|
||||||
|
abbestellen?.()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="watched-tab">
|
<div class="watched-tab">
|
||||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
<p v-else-if="!laden && !sichtbar.length" class="note">
|
<p v-else-if="!laden && !seiten.length" class="note">Noch kein Film geschaut.</p>
|
||||||
Noch kein Film geschaut.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="grid">
|
<template v-else-if="seiten.length">
|
||||||
<article
|
<div
|
||||||
v-for="item in sichtbar"
|
ref="buch"
|
||||||
:key="item.id"
|
class="buch"
|
||||||
class="karte"
|
@scroll.passive="beimBlaettern"
|
||||||
:class="{ versteckt: item.hidden }"
|
@pointerdown="ziehenStart"
|
||||||
|
@pointermove="ziehen"
|
||||||
|
@pointerup="ziehenEnde"
|
||||||
|
@pointercancel="ziehenEnde"
|
||||||
|
@click.capture="klickAbfangen"
|
||||||
|
@click="seiteWaehlen"
|
||||||
>
|
>
|
||||||
<button type="button" class="cover" @click="oeffnen(item)">
|
<Abend
|
||||||
<img
|
v-for="item in seiten"
|
||||||
v-if="posterUrl(item.poster_path, 'w342')"
|
:key="item.id"
|
||||||
:src="posterUrl(item.poster_path, 'w342')"
|
class="blatt"
|
||||||
:alt="item.title"
|
:item="item"
|
||||||
loading="lazy"
|
:host="host"
|
||||||
/>
|
:angemeldet="angemeldet"
|
||||||
<span v-else class="kein-cover">Kein Cover</span>
|
:runde="runde"
|
||||||
|
@open="emit('open', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="stand">
|
||||||
|
<button type="button" :disabled="aktuell <= 0" aria-label="Zurück" @click="blaettern(-1)">
|
||||||
|
‹
|
||||||
</button>
|
</button>
|
||||||
|
<span>{{ aktuell + 1 }} / {{ seiten.length }}</span>
|
||||||
<div class="text">
|
<button
|
||||||
<h3 @click="oeffnen(item)">{{ item.title }}</h3>
|
type="button"
|
||||||
<p class="meta">
|
:disabled="aktuell >= seiten.length - 1"
|
||||||
<span v-if="item.year">{{ item.year }}</span>
|
aria-label="Weiter"
|
||||||
<span v-if="host" class="datum-feld">
|
@click="blaettern(1)"
|
||||||
Geschaut am
|
>
|
||||||
<input
|
›
|
||||||
type="date"
|
</button>
|
||||||
:value="alsFeld(item.watched_at)"
|
</p>
|
||||||
@change="datumSetzen(item, $event.target.value)"
|
</template>
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span v-else>Geschaut am {{ datum(item.watched_at) }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="schnitt">
|
|
||||||
<template v-if="item.count">
|
|
||||||
<span class="wert">{{ item.average.toFixed(1) }}</span>
|
|
||||||
<span class="von">von 5 ({{ item.count }})</span>
|
|
||||||
</template>
|
|
||||||
<span v-else class="von">Noch keine Wertung</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
|
|
||||||
<button
|
|
||||||
v-for="s in STERNE"
|
|
||||||
:key="s"
|
|
||||||
type="button"
|
|
||||||
class="stern"
|
|
||||||
:class="{ an: gefuellt(item, s) }"
|
|
||||||
:aria-label="`${s} von 5`"
|
|
||||||
@mouseenter="schwebt = { id: item.id, wert: s }"
|
|
||||||
@click="bewerten(item, s)"
|
|
||||||
>
|
|
||||||
★
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-if="item.ratings.length" class="stimmen">
|
|
||||||
<li v-for="r in item.ratings" :key="r.id">
|
|
||||||
<span class="wer">{{ r.name || 'Anonym' }}</span>
|
|
||||||
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
|
|
||||||
<button
|
|
||||||
v-if="host"
|
|
||||||
type="button"
|
|
||||||
class="weg-klein"
|
|
||||||
title="Diese Wertung entfernen"
|
|
||||||
@click="wertungEntfernen(r)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div v-if="host" class="aktionen">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="icon"
|
|
||||||
:title="item.hidden ? 'Wieder einblenden' : 'Ausblenden'"
|
|
||||||
@click="sichtbarkeit(item)"
|
|
||||||
>
|
|
||||||
<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="item.hidden" x1="3" y1="21" x2="21" y2="3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="icon loeschen"
|
|
||||||
title="Samt Bewertungen löschen"
|
|
||||||
@click="entfernen(item)"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.grid {
|
.watched-tab {
|
||||||
display: grid;
|
/* Eine Doppelseite: links das Cover im Posterformat, rechts genauso
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
breit die Texte. So breit, dass sie in die Höhe passt, aber nie
|
||||||
gap: 1.1rem;
|
breiter als das Fenster. */
|
||||||
|
--seite: min(100%, calc((100vh - 9.5rem) * 4 / 3));
|
||||||
|
position: relative;
|
||||||
|
/* Kopfzeile, Polster des Reiters und der Seitenzähler darunter —
|
||||||
|
das Buch bekommt den Rest, ohne dass die Seite selbst scrollt */
|
||||||
|
height: calc(100vh - 8rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.karte {
|
.buch {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.9rem;
|
height: 100%;
|
||||||
align-items: flex-start;
|
overflow-x: auto;
|
||||||
padding: 0.8rem;
|
overflow-y: hidden;
|
||||||
background: var(--bg-soft);
|
scrollbar-width: none;
|
||||||
border: 1px solid var(--line);
|
-webkit-overflow-scrolling: touch;
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.buch::-webkit-scrollbar {
|
||||||
flex-shrink: 0;
|
display: none;
|
||||||
width: 96px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover img,
|
/* Die Maus zeigt an, dass man ziehen kann; beim Ziehen setzt das Skript
|
||||||
.kein-cover {
|
den greifenden Zeiger direkt am Element */
|
||||||
width: 96px;
|
.buch {
|
||||||
aspect-ratio: 2 / 3;
|
cursor: grab;
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kein-cover {
|
.buch .blatt {
|
||||||
display: grid;
|
cursor: inherit;
|
||||||
place-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
/* Abstandhalter, damit auch die erste und die letzte Seite in der Mitte
|
||||||
display: flex;
|
liegen können */
|
||||||
flex-direction: column;
|
.buch::before,
|
||||||
gap: 0.3rem;
|
.buch::after {
|
||||||
min-width: 0;
|
flex: 0 0 calc((100% - var(--seite)) / 2);
|
||||||
|
content: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.blatt {
|
||||||
font-size: 1rem;
|
flex: 0 0 var(--seite);
|
||||||
line-height: 1.25;
|
height: 100%;
|
||||||
cursor: pointer;
|
/* Die beiden Hälften scrollen für sich, das Blatt selbst nicht */
|
||||||
}
|
|
||||||
|
|
||||||
h3:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datum-feld {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datum-feld input {
|
|
||||||
padding: 0.15rem 0.35rem;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schnitt {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wert {
|
|
||||||
color: #f5c518;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.von {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wertung {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stern {
|
|
||||||
padding: 0 0.05rem;
|
|
||||||
color: var(--line);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stern.an {
|
|
||||||
color: #f5c518;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stimmen {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stimmen li {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wer {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
border-right: 1px solid var(--line);
|
||||||
text-overflow: ellipsis;
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.punkte {
|
.stand {
|
||||||
color: #f5c518;
|
position: absolute;
|
||||||
}
|
bottom: -1.7rem;
|
||||||
|
left: 50%;
|
||||||
.weg-klein {
|
display: flex;
|
||||||
padding: 0 0.25rem;
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.75rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(10, 10, 12, 0.8);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stand button {
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weg-klein:hover {
|
.stand button:hover:not(:disabled) {
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aktionen {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 1.9rem;
|
|
||||||
height: 1.9rem;
|
|
||||||
color: var(--muted);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon svg {
|
|
||||||
width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1.8;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon:hover {
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon.loeschen:hover {
|
.stand button:disabled {
|
||||||
color: var(--accent);
|
opacity: 0.3;
|
||||||
border-color: var(--accent);
|
cursor: default;
|
||||||
}
|
|
||||||
|
|
||||||
/* Der Host sieht sofort, was für die anderen weg ist */
|
|
||||||
.karte.versteckt {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
@@ -419,4 +392,17 @@ h3:hover {
|
|||||||
.note.error {
|
.note.error {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.watched-tab {
|
||||||
|
--seite: 100%;
|
||||||
|
height: calc(100dvh - 7.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf dem Handy liegen die Hälften untereinander, das Blatt scrollt */
|
||||||
|
.blatt {
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,31 +1,35 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import { abonnieren } from '../live'
|
import { abonnieren } from '../live'
|
||||||
|
import { klang } from '../klang'
|
||||||
|
import { useZiehung } from '../composables/useZiehung'
|
||||||
|
import VorschlagsListe from './VorschlagsListe.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
defineProps({ host: Boolean })
|
const props = defineProps({
|
||||||
|
host: Boolean,
|
||||||
|
angemeldet: Boolean,
|
||||||
|
// Wer gerade gewählt ist — "Dabei ohne Vorschlag" gehört zur Person
|
||||||
|
ich: { type: Object, default: null },
|
||||||
|
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
||||||
|
merkliste: { type: Array, default: () => [] },
|
||||||
|
mine: { type: Number, default: null },
|
||||||
|
gesehenIds: { type: Set, default: () => new Set() },
|
||||||
|
})
|
||||||
const emit = defineEmits(['open', 'reset'])
|
const emit = defineEmits(['open', 'reset'])
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const mine = ref(null)
|
// Wer ohne Vorschlag dabei ist — zählt beim Dreh als anwesend
|
||||||
|
const dabei = ref([])
|
||||||
|
const dabeiMine = ref(false)
|
||||||
const laden = ref(true)
|
const laden = ref(true)
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
|
|
||||||
const winkel = ref(0)
|
const {
|
||||||
const dreht = ref(false)
|
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
|
||||||
const gewinner = ref(null)
|
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
|
||||||
const dauer = ref(4.2)
|
} = useZiehung({ items })
|
||||||
|
|
||||||
const GROESSE = 420
|
|
||||||
const MITTE = GROESSE / 2
|
|
||||||
const RADIUS = MITTE - 6
|
|
||||||
|
|
||||||
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
|
||||||
// verschwimmt der Zeiger mit dem Rad
|
|
||||||
const FARBEN = [
|
|
||||||
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
|
||||||
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||||
let abbestellen = null
|
let abbestellen = null
|
||||||
@@ -34,49 +38,12 @@ function verbinden() {
|
|||||||
abbestellen = abonnieren((nachricht) => {
|
abbestellen = abonnieren((nachricht) => {
|
||||||
if (nachricht.type === 'suggestions') laden_()
|
if (nachricht.type === 'suggestions') laden_()
|
||||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||||
else if (nachricht.type === 'reset') zurueckgesetzt()
|
else if (nachricht.type === 'reset') {
|
||||||
})
|
zurueckgesetzt()
|
||||||
}
|
emit('reset')
|
||||||
|
laden_()
|
||||||
function abspielen(ergebnis, uebersprungen = 0) {
|
|
||||||
gewinner.value = null
|
|
||||||
dreht.value = true
|
|
||||||
|
|
||||||
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
|
|
||||||
if (uebersprungen > 0) {
|
|
||||||
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
|
|
||||||
dauer.value = rest
|
|
||||||
} else {
|
|
||||||
dauer.value = ergebnis.dauer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
|
|
||||||
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
dreht.value = false
|
|
||||||
gewinner.value = ergebnis.gewinner
|
|
||||||
}, rest * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function laufendenDrehHolen() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/spin')
|
|
||||||
if (!resp.ok) return
|
|
||||||
const daten = await resp.json()
|
|
||||||
if (!daten.result) return
|
|
||||||
if (daten.running) {
|
|
||||||
abspielen(daten.result, daten.elapsed)
|
|
||||||
} else {
|
|
||||||
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
|
|
||||||
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
|
|
||||||
dauer.value = 0
|
|
||||||
winkel.value = daten.result.winkel
|
|
||||||
gewinner.value = daten.result.gewinner
|
|
||||||
}
|
}
|
||||||
} catch {
|
})
|
||||||
/* dann eben ohne */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function laden_() {
|
async function laden_() {
|
||||||
@@ -87,7 +54,9 @@ async function laden_() {
|
|||||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||||
const daten = await resp.json()
|
const daten = await resp.json()
|
||||||
items.value = daten.items
|
items.value = daten.items
|
||||||
mine.value = daten.mine
|
dabei.value = daten.dabei || []
|
||||||
|
dabeiMine.value = !!daten.dabei_mine
|
||||||
|
ruhen()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fehler.value = err.message
|
fehler.value = err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,86 +64,19 @@ async function laden_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await laden_()
|
|
||||||
await laufendenDrehHolen()
|
|
||||||
verbinden()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => abbestellen?.())
|
|
||||||
|
|
||||||
defineExpose({ neu: laden_ })
|
|
||||||
|
|
||||||
const segmente = computed(() => {
|
|
||||||
const n = items.value.length
|
|
||||||
if (!n) return []
|
|
||||||
const schritt = 360 / n
|
|
||||||
return items.value.map((item, i) => {
|
|
||||||
const start = i * schritt
|
|
||||||
const ende = start + schritt
|
|
||||||
const mitte = start + schritt / 2
|
|
||||||
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
|
|
||||||
// von außen nach innen
|
|
||||||
const gespiegelt = mitte > 180
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
farbe: FARBEN[i % FARBEN.length],
|
|
||||||
pfad: kreisstueck(start, ende),
|
|
||||||
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
|
|
||||||
gespiegelt,
|
|
||||||
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
|
|
||||||
breit: schritt > 9,
|
|
||||||
kurz: kuerzen(item.title, schritt),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function kuerzen(titel, schritt) {
|
|
||||||
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
|
|
||||||
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
|
|
||||||
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
|
|
||||||
}
|
|
||||||
|
|
||||||
function punkt(grad) {
|
|
||||||
const rad = ((grad - 90) * Math.PI) / 180
|
|
||||||
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function kreisstueck(von, bis) {
|
|
||||||
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
|
|
||||||
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
|
|
||||||
if (bis - von >= 360) {
|
|
||||||
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
|
|
||||||
MITTE + RADIUS
|
|
||||||
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
|
|
||||||
}
|
|
||||||
const [x1, y1] = punkt(von)
|
|
||||||
const [x2, y2] = punkt(bis)
|
|
||||||
const gross = bis - von > 180 ? 1 : 0
|
|
||||||
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drehen() {
|
async function drehen() {
|
||||||
if (dreht.value || !items.value.length) return
|
if (dreht.value || !items.value.length) return
|
||||||
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
|
||||||
|
// aus und schickt das Ergebnis an alle, auch an uns.
|
||||||
|
klang()
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/spin', { method: 'POST' })
|
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||||
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
|
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
|
||||||
} catch {
|
} catch {
|
||||||
fehler.value = 'Drehen hat nicht geklappt.'
|
fehler.value = 'Ziehen hat nicht geklappt.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zurueckgesetzt() {
|
|
||||||
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
|
|
||||||
dauer.value = 0
|
|
||||||
winkel.value = 0
|
|
||||||
gewinner.value = null
|
|
||||||
dreht.value = false
|
|
||||||
emit('reset')
|
|
||||||
laden_()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function zuruecksetzen() {
|
async function zuruecksetzen() {
|
||||||
// Der Server meldet den Reset an alle zurück, auch an uns
|
// Der Server meldet den Reset an alle zurück, auch an uns
|
||||||
try {
|
try {
|
||||||
@@ -188,6 +90,21 @@ async function oeffnen(item) {
|
|||||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||||
if (resp.ok) emit('open', await resp.json())
|
if (resp.ok) emit('open', await resp.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ich?.id,
|
||||||
|
() => laden_()
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await laden_()
|
||||||
|
await laufendenDrehHolen()
|
||||||
|
verbinden()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => abbestellen?.())
|
||||||
|
|
||||||
|
defineExpose({ neu: laden_ })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -195,38 +112,48 @@ async function oeffnen(item) {
|
|||||||
<div class="buehne">
|
<div class="buehne">
|
||||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
|
||||||
<div class="rad-halter">
|
<div
|
||||||
<div class="zeiger"></div>
|
class="fenster"
|
||||||
<svg
|
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
|
||||||
class="rad"
|
>
|
||||||
|
<div class="marke" :class="{ anlauf, bruch }"></div>
|
||||||
|
<div
|
||||||
|
ref="bahn"
|
||||||
|
class="bahn"
|
||||||
:class="{ dreht }"
|
:class="{ dreht }"
|
||||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
|
||||||
:style="{
|
:style="{
|
||||||
transform: `rotate(${winkel}deg)`,
|
transform: `translateX(${x}px)`,
|
||||||
transitionDuration: `${dauer}s`,
|
transitionDuration: `${dauer}s`,
|
||||||
|
transitionTimingFunction: kurve,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
|
<div
|
||||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
v-for="k in kacheln"
|
||||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
:key="k.key"
|
||||||
<text
|
class="kachel"
|
||||||
v-if="seg.breit"
|
:class="{
|
||||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
treffer: k.treffer && !dreht && gewinner,
|
||||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
gesehen: gesehenIds.has(k.film.movie_id),
|
||||||
:y="MITTE + 4"
|
}"
|
||||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
:style="{ borderColor: k.farbe }"
|
||||||
class="beschriftung"
|
>
|
||||||
>
|
<img
|
||||||
{{ seg.kurz }}
|
v-if="posterUrl(k.film.poster_path, 'w342')"
|
||||||
</text>
|
:src="posterUrl(k.film.poster_path, 'w342')"
|
||||||
</g>
|
:alt="k.film.title"
|
||||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
draggable="false"
|
||||||
</svg>
|
/>
|
||||||
|
<span v-else class="ohne-cover">{{ k.film.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!items.length && !laden && !ergebnis" class="leer-bahn">
|
||||||
|
Noch nichts vorgeschlagen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="host" class="knoepfe">
|
<div v-if="host" class="knoepfe">
|
||||||
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
||||||
{{ dreht ? 'dreht…' : 'Drehen' }}
|
{{ dreht ? 'läuft…' : 'Ziehen' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="reset"
|
class="reset"
|
||||||
@@ -248,30 +175,25 @@ async function oeffnen(item) {
|
|||||||
<p class="label">Der Film des Abends</p>
|
<p class="label">Der Film des Abends</p>
|
||||||
<h2>{{ gewinner.title }}</h2>
|
<h2>{{ gewinner.title }}</h2>
|
||||||
<p class="jahr">
|
<p class="jahr">
|
||||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
{{ gewinner.year }}<span v-if="gewinner.name"> · von <PersonName :name="gewinner.name" /></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="liste">
|
<VorschlagsListe
|
||||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
:items="items"
|
||||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
:laden="laden"
|
||||||
<ul>
|
:host="host"
|
||||||
<li v-for="(item, i) in items" :key="item.id">
|
:angemeldet="angemeldet"
|
||||||
<button
|
:merkliste="merkliste"
|
||||||
type="button"
|
:mine="mine"
|
||||||
class="eintrag"
|
:gesehen-ids="gesehenIds"
|
||||||
:class="{ eigen: item.id === mine }"
|
:dabei="dabei"
|
||||||
@click="oeffnen(item)"
|
:dabei-mine="dabeiMine"
|
||||||
>
|
@open="oeffnen"
|
||||||
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
|
@fehler="fehler = $event"
|
||||||
<span class="titel">{{ item.title }}</span>
|
/>
|
||||||
<span class="von">{{ item.name || '—' }}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -288,41 +210,163 @@ async function oeffnen(item) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rad-halter {
|
/* ── Die Bahn ────────────────────────────────────────────────────── */
|
||||||
|
.fenster {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(420px, 80vw);
|
align-self: stretch;
|
||||||
|
/* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */
|
||||||
|
height: calc(var(--kachel) * 1.5 + 40px);
|
||||||
|
overflow: hidden;
|
||||||
|
/* Links und rechts läuft der Streifen ins Dunkel aus */
|
||||||
|
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rad {
|
.bahn {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--luecke);
|
||||||
|
will-change: transform;
|
||||||
|
/* Die Kurve setzt das Skript je Phase */
|
||||||
|
transition-property: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 var(--kachel);
|
||||||
|
height: calc(var(--kachel) * 1.5);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 0.4s, box-shadow 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
|
height: 100%;
|
||||||
transition-property: transform;
|
object-fit: cover;
|
||||||
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.beschriftung {
|
.ohne-cover {
|
||||||
fill: #fff;
|
display: grid;
|
||||||
font-size: 13px;
|
height: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
paint-order: stroke;
|
text-align: center;
|
||||||
stroke: rgba(0, 0, 0, 0.45);
|
|
||||||
stroke-width: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zeiger {
|
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
||||||
|
.kachel.gesehen {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Gewinner blitzt auf, sobald die Bahn steht */
|
||||||
|
.kachel.treffer {
|
||||||
|
z-index: 1;
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
|
||||||
|
animation: aufblitzen 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aufblitzen {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 0 0 0 #fff, 0 0 0 #fff;
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
transform: scale(1.14);
|
||||||
|
box-shadow: 0 0 0 4px #fff, 0 0 60px #fff;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
width: 3px;
|
||||||
|
margin-left: -1.5px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 10px var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::before,
|
||||||
|
.marke::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
margin-left: -11px;
|
margin-left: -9px;
|
||||||
border-top: 20px solid var(--text);
|
content: '';
|
||||||
border-right: 11px solid transparent;
|
border-right: 9px solid transparent;
|
||||||
border-left: 11px solid transparent;
|
border-left: 9px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::before {
|
||||||
|
top: 0;
|
||||||
|
border-top: 14px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke::after {
|
||||||
|
bottom: 0;
|
||||||
|
border-bottom: 14px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
|
||||||
|
.marke.anlauf {
|
||||||
|
animation: puls 0.33s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes puls {
|
||||||
|
50% {
|
||||||
|
transform: scaleX(2.5);
|
||||||
|
box-shadow: 0 0 26px var(--accent), 0 0 6px #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Halterung bricht: die Marke schlackert kurz */
|
||||||
|
.marke.bruch {
|
||||||
|
transform-origin: top center;
|
||||||
|
animation: wackeln 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wackeln {
|
||||||
|
20% {
|
||||||
|
transform: rotate(9deg);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: rotate(4deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leer-bahn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.knoepfe {
|
.knoepfe {
|
||||||
@@ -330,6 +374,21 @@ async function oeffnen(item) {
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dreh {
|
||||||
|
padding: 0.7rem 2.2rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dreh:disabled {
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.reset {
|
.reset {
|
||||||
padding: 0.7rem 1.4rem;
|
padding: 0.7rem 1.4rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -349,37 +408,29 @@ async function oeffnen(item) {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leer {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dreh {
|
|
||||||
padding: 0.7rem 2.2rem;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dreh:disabled {
|
|
||||||
background: var(--line);
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gewinner {
|
.gewinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 420px;
|
max-width: 640px;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
animation: aufsteigen 0.45s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aufsteigen {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gewinner img {
|
.gewinner img {
|
||||||
@@ -406,69 +457,6 @@ async function oeffnen(item) {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste h3 {
|
|
||||||
margin-bottom: 0.7rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.09em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anzahl {
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
background: var(--bg-soft);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.liste ul {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eintrag {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
text-align: left;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eintrag:hover {
|
|
||||||
background: var(--bg-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eintrag.eigen .titel {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.punkt {
|
|
||||||
width: 0.6rem;
|
|
||||||
height: 0.6rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titel {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.von {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
padding: 3rem 0;
|
padding: 3rem 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,183 @@
|
|||||||
|
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht und
|
||||||
|
// der Schalter für gesehene Filme — alles im localStorage, alles je
|
||||||
|
// Browser. Der Hostmodus nicht: der hängt an der Sitzung auf dem Server.
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
|
||||||
|
// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt.
|
||||||
|
async function hostSetzen(an, schluessel = null, movieId = null) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/host', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ an, schluessel, movie_id: movieId }),
|
||||||
|
})
|
||||||
|
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, film: false }
|
||||||
|
} catch {
|
||||||
|
return { host: false, film: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_IDS = [
|
||||||
|
'katalog',
|
||||||
|
'suche',
|
||||||
|
'wheel',
|
||||||
|
'merkliste',
|
||||||
|
'watched',
|
||||||
|
'wuensche',
|
||||||
|
'info',
|
||||||
|
'verwaltung',
|
||||||
|
'stream',
|
||||||
|
'clips',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
|
||||||
|
const QUELLEN = ['netflix', 'canon']
|
||||||
|
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
|
||||||
|
const SUCHARTEN = ['search', 'aehnlich', 'personen', 'ki']
|
||||||
|
|
||||||
|
function gemerkt(schluessel, standard) {
|
||||||
|
const wert = ref(localStorage.getItem(schluessel) ?? standard)
|
||||||
|
watch(wert, (neu) => localStorage.setItem(schluessel, neu))
|
||||||
|
return wert
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEinstellungen() {
|
||||||
|
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||||
|
const ansicht = gemerkt('filmabend_ansicht', 'kompakt')
|
||||||
|
|
||||||
|
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
|
||||||
|
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
||||||
|
const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
|
||||||
|
watch(ohneGesehene, (an) => localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus'))
|
||||||
|
|
||||||
|
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
|
||||||
|
// alter Reiter "netflix" oder "canon" landet im Katalog.
|
||||||
|
const zuletzt = localStorage.getItem('filmabend_tab')
|
||||||
|
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'katalog')
|
||||||
|
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
|
||||||
|
|
||||||
|
// Welche Liste der Katalog zeigt: Netflix oder den Horror-Kanon
|
||||||
|
const quelle = gemerkt('filmabend_quelle', QUELLEN.includes(zuletzt) ? zuletzt : 'netflix')
|
||||||
|
if (!QUELLEN.includes(quelle.value)) quelle.value = 'netflix'
|
||||||
|
|
||||||
|
// Welche Suche offen ist: Titel, Ähnliche oder KI
|
||||||
|
const suchArt = gemerkt('filmabend_suchart', SUCHARTEN.includes(zuletzt) ? zuletzt : 'search')
|
||||||
|
if (!SUCHARTEN.includes(suchArt.value)) suchArt.value = 'search'
|
||||||
|
if (SUCHARTEN.includes(zuletzt)) activeTab.value = 'suche'
|
||||||
|
|
||||||
|
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
|
||||||
|
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
|
||||||
|
// der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet
|
||||||
|
// die Tastenkombination um, damit der Host die Gastansicht sehen kann.
|
||||||
|
const freigeschaltet = ref(false)
|
||||||
|
const anzeige = ref(localStorage.getItem('filmabend_host') !== '0')
|
||||||
|
const host = computed(() => freigeschaltet.value && anzeige.value)
|
||||||
|
|
||||||
|
// Der Host-Link: /?host=<schlüssel>. Nach dem Einlösen fliegt er aus der
|
||||||
|
// Adresszeile, damit er nicht im Verlauf oder in einem Screenshot landet.
|
||||||
|
async function linkEinloesen() {
|
||||||
|
const adresse = new URL(window.location.href)
|
||||||
|
const schluessel = adresse.searchParams.get('host')
|
||||||
|
if (!schluessel) return false
|
||||||
|
const an = await hostSetzen(true, schluessel)
|
||||||
|
adresse.searchParams.delete('host')
|
||||||
|
window.history.replaceState({}, '', adresse)
|
||||||
|
return an
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Der Link kommt zuerst: er schaltet frei und zeigt die Knöpfe gleich
|
||||||
|
if (await linkEinloesen()) {
|
||||||
|
freigeschaltet.value = true
|
||||||
|
anzeige.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
freigeschaltet.value = (await hostStand()).host
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
|
||||||
|
|
||||||
|
// Ohne Freischaltung fragt die Tastenkombination nach dem Host-Film —
|
||||||
|
// wie der Anmeldefilm bei einem Namen
|
||||||
|
const hostLogin = ref(false)
|
||||||
|
|
||||||
|
async function hostAnmelden(movieId) {
|
||||||
|
const an = await hostSetzen(true, null, movieId)
|
||||||
|
if (!an) return false
|
||||||
|
freigeschaltet.value = true
|
||||||
|
anzeige.value = true
|
||||||
|
hostLogin.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function umschalten() {
|
||||||
|
if (freigeschaltet.value) anzeige.value = !anzeige.value
|
||||||
|
// Ohne Host-Film kommt man so rein; lehnt der Server ab, gibt es einen
|
||||||
|
else if (!(await hostAnmelden(null))) hostLogin.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function tastatur(e) {
|
||||||
|
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
||||||
|
// je nach System ein anderes Zeichen
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
||||||
|
e.preventDefault()
|
||||||
|
umschalten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Am Handy gibt es keine Tastenkombination: fünf Sekunden mit einem
|
||||||
|
// Finger den Bildschirm halten tut dasselbe
|
||||||
|
const DRUCK_MS = 5000
|
||||||
|
let druck = null
|
||||||
|
let druckStart = null
|
||||||
|
|
||||||
|
function fingerAb() {
|
||||||
|
clearTimeout(druck)
|
||||||
|
druck = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerAuf(e) {
|
||||||
|
fingerAb()
|
||||||
|
if (e.touches.length !== 1) return
|
||||||
|
druckStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||||
|
druck = setTimeout(umschalten, DRUCK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerBewegt(e) {
|
||||||
|
if (!druck) return
|
||||||
|
const t = e.touches[0]
|
||||||
|
// Ein bisschen Zittern ist kein Wischen
|
||||||
|
if (Math.hypot(t.clientX - druckStart.x, t.clientY - druckStart.y) > 15) fingerAb()
|
||||||
|
}
|
||||||
|
|
||||||
|
const finger = {
|
||||||
|
touchstart: fingerAuf,
|
||||||
|
touchmove: fingerBewegt,
|
||||||
|
touchend: fingerAb,
|
||||||
|
touchcancel: fingerAb,
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', tastatur)
|
||||||
|
for (const [ev, fn] of Object.entries(finger))
|
||||||
|
window.addEventListener(ev, fn, { passive: true })
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', tastatur)
|
||||||
|
for (const [ev, fn] of Object.entries(finger)) window.removeEventListener(ev, fn)
|
||||||
|
fingerAb()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden }
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Die Filterleiste: Genres, Regler, Sortierung, Suche — und die Prädikate,
|
||||||
|
// mit denen der Katalog danach gesiebt wird.
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||||
|
// sonst wandern die Enden bei jeder Auswahl
|
||||||
|
export const REGLER = [
|
||||||
|
{
|
||||||
|
key: 'vote_count',
|
||||||
|
label: 'Bewertungen',
|
||||||
|
feld: 'vote_count',
|
||||||
|
step: 1,
|
||||||
|
gestaucht: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runtime',
|
||||||
|
label: 'Dauer',
|
||||||
|
feld: 'runtime',
|
||||||
|
step: 1,
|
||||||
|
einheit: ' Min.',
|
||||||
|
},
|
||||||
|
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
|
||||||
|
{
|
||||||
|
key: 'vote_average',
|
||||||
|
label: 'Bewertung',
|
||||||
|
feld: 'vote_average',
|
||||||
|
step: 0.1,
|
||||||
|
nachkomma: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
|
||||||
|
// ließen — TMDB reicht von den Anfängen bis heute
|
||||||
|
const SUCH_GRENZEN = {
|
||||||
|
vote_count: { min: 0, max: 50000 },
|
||||||
|
runtime: { min: 0, max: 240 },
|
||||||
|
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||||
|
vote_average: { min: 0, max: 10 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die 19 Genres, die TMDB kennt
|
||||||
|
const SUCH_GENRES = [
|
||||||
|
'Abenteuer',
|
||||||
|
'Action',
|
||||||
|
'Animation',
|
||||||
|
'Dokumentarfilm',
|
||||||
|
'Drama',
|
||||||
|
'Familie',
|
||||||
|
'Fantasy',
|
||||||
|
'Historie',
|
||||||
|
'Horror',
|
||||||
|
'Komödie',
|
||||||
|
'Krimi',
|
||||||
|
'Kriegsfilm',
|
||||||
|
'Liebesfilm',
|
||||||
|
'Musik',
|
||||||
|
'Mystery',
|
||||||
|
'Science Fiction',
|
||||||
|
'TV-Film',
|
||||||
|
'Thriller',
|
||||||
|
'Western',
|
||||||
|
].map((genre) => ({ genre, count: null }))
|
||||||
|
|
||||||
|
export function useFilter({
|
||||||
|
activeTab,
|
||||||
|
quelle,
|
||||||
|
suchArt,
|
||||||
|
netflixMovies,
|
||||||
|
canonMovies,
|
||||||
|
gesehenIds,
|
||||||
|
ohneGesehene,
|
||||||
|
}) {
|
||||||
|
const search = ref('')
|
||||||
|
const sort = ref('random')
|
||||||
|
// Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
|
||||||
|
// von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
|
||||||
|
// zuerst. Die Wahl bleibt je Suche erhalten.
|
||||||
|
const suchSorts = ref({
|
||||||
|
search: 'relevanz',
|
||||||
|
aehnlich: 'random',
|
||||||
|
personen: 'newest',
|
||||||
|
})
|
||||||
|
const suchSort = computed({
|
||||||
|
get: () => suchSorts.value[suchArt.value] ?? 'relevanz',
|
||||||
|
set: (wert) => {
|
||||||
|
suchSorts.value[suchArt.value] = wert
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wie viele Treffer eine Suche bringen soll — je Suche gemerkt. Bei
|
||||||
|
// Personen gibt es das nicht: da sind es eben alle Filme.
|
||||||
|
const suchAnzahlen = ref({ search: 24, aehnlich: 20, ki: 12 })
|
||||||
|
const suchAnzahl = computed({
|
||||||
|
get: () => suchAnzahlen.value[suchArt.value] ?? 24,
|
||||||
|
set: (wert) => {
|
||||||
|
suchAnzahlen.value[suchArt.value] = wert
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const include = ref([])
|
||||||
|
const exclude = ref([])
|
||||||
|
|
||||||
|
const suchReiter = () => activeTab.value === 'suche'
|
||||||
|
const pool = () => (quelle.value === 'netflix' ? netflixMovies.value : canonMovies.value)
|
||||||
|
|
||||||
|
const grenzen = computed(() => {
|
||||||
|
if (suchReiter()) return SUCH_GRENZEN
|
||||||
|
const out = {}
|
||||||
|
for (const regler of REGLER) {
|
||||||
|
const werte = pool()
|
||||||
|
.map((m) => m[regler.feld])
|
||||||
|
.filter((v) => v != null)
|
||||||
|
const min = werte.length ? Math.min(...werte) : 0
|
||||||
|
const max = werte.length ? Math.max(...werte) : 0
|
||||||
|
out[regler.key] = {
|
||||||
|
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
|
||||||
|
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const ranges = ref({})
|
||||||
|
|
||||||
|
// Im Suche-Reiter starten zwei Regler nicht am Anschlag: unter 60
|
||||||
|
// Minuten sind es Kurzfilme, unter 50 Stimmen Making-ofs und Cameos.
|
||||||
|
// Wer das sehen will, zieht den Regler runter — bewusst.
|
||||||
|
const SUCH_START = { runtime: 60, vote_count: 50 }
|
||||||
|
|
||||||
|
function resetRanges() {
|
||||||
|
ranges.value = Object.fromEntries(
|
||||||
|
Object.entries(grenzen.value).map(([k, v]) => [
|
||||||
|
k,
|
||||||
|
[suchReiter() && SUCH_START[k] != null ? SUCH_START[k] : v.min, v.max],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
|
||||||
|
watch(grenzen, resetRanges, { immediate: true })
|
||||||
|
|
||||||
|
function imBereich(movie) {
|
||||||
|
for (const regler of REGLER) {
|
||||||
|
const spanne = ranges.value[regler.key]
|
||||||
|
const grenze = grenzen.value[regler.key]
|
||||||
|
if (!spanne) continue
|
||||||
|
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
|
||||||
|
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
|
||||||
|
const wert = movie[regler.feld]
|
||||||
|
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Genres anbieten, die in der gewählten Quelle auch Treffer haben
|
||||||
|
const genreOptions = computed(() => {
|
||||||
|
if (suchReiter()) return SUCH_GENRES
|
||||||
|
const counts = new Map()
|
||||||
|
for (const movie of pool()) {
|
||||||
|
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
|
||||||
|
}
|
||||||
|
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
|
||||||
|
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
|
||||||
|
.map(([genre, count]) => ({ genre, count }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleGenre(kind, genre) {
|
||||||
|
const liste = kind === 'include' ? include : exclude
|
||||||
|
const andere = kind === 'include' ? exclude : include
|
||||||
|
const at = liste.value.indexOf(genre)
|
||||||
|
if (at === -1) {
|
||||||
|
liste.value = [...liste.value, genre]
|
||||||
|
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
|
||||||
|
andere.value = andere.value.filter((g) => g !== genre)
|
||||||
|
} else {
|
||||||
|
liste.value = liste.value.filter((g) => g !== genre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(kind) {
|
||||||
|
const liste = kind === 'include' ? include : exclude
|
||||||
|
const andere = kind === 'include' ? exclude : include
|
||||||
|
const alle = genreOptions.value.map((o) => o.genre)
|
||||||
|
// Sind schon alle drin, ist der zweite Klick ein Leeren
|
||||||
|
if (liste.value.length === alle.length) {
|
||||||
|
liste.value = []
|
||||||
|
} else {
|
||||||
|
liste.value = alle
|
||||||
|
andere.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGenres() {
|
||||||
|
include.value = []
|
||||||
|
exclude.value = []
|
||||||
|
resetRanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
|
||||||
|
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
|
||||||
|
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||||
|
function hasGenre(movie) {
|
||||||
|
const genres = movie.genres || []
|
||||||
|
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !include.value.length || genres.some((g) => include.value.includes(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nichtGesehen(movie) {
|
||||||
|
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function passt(movie) {
|
||||||
|
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
|
||||||
|
const suchFilter = computed(() => {
|
||||||
|
const spanne = (key) => ranges.value[key] || []
|
||||||
|
return {
|
||||||
|
sort: suchSort.value,
|
||||||
|
anzahl: suchAnzahl.value,
|
||||||
|
include: include.value,
|
||||||
|
exclude: exclude.value,
|
||||||
|
stimmen: spanne('vote_count'),
|
||||||
|
dauer: spanne('runtime'),
|
||||||
|
jahr: spanne('year'),
|
||||||
|
note: spanne('vote_average'),
|
||||||
|
grenzen: grenzen.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
suchSort,
|
||||||
|
suchAnzahl,
|
||||||
|
include,
|
||||||
|
exclude,
|
||||||
|
grenzen,
|
||||||
|
ranges,
|
||||||
|
genreOptions,
|
||||||
|
toggleGenre,
|
||||||
|
toggleAll,
|
||||||
|
resetGenres,
|
||||||
|
nichtGesehen,
|
||||||
|
passt,
|
||||||
|
suchFilter,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Der Katalog: alle Filme aus der Datenbank, aufgeteilt in Netflix und
|
||||||
|
// Kanon, zu Reihen gruppiert und sortierbar.
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
||||||
|
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||||
|
const MIN_VOTES = 150
|
||||||
|
|
||||||
|
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
|
||||||
|
// Karten bei jedem Filtern neu durcheinander.
|
||||||
|
const wuerfel = new Map()
|
||||||
|
|
||||||
|
function zufall(movie) {
|
||||||
|
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
|
||||||
|
return wuerfel.get(movie.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
|
||||||
|
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
|
||||||
|
function groupBy(movies, schluessel) {
|
||||||
|
const byKey = new Map()
|
||||||
|
const einzeln = []
|
||||||
|
for (const movie of movies) {
|
||||||
|
const key = schluessel(movie)
|
||||||
|
if (key === null || key === undefined) {
|
||||||
|
einzeln.push(movie)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!byKey.has(key)) byKey.set(key, [])
|
||||||
|
byKey.get(key).push(movie)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gruppen = []
|
||||||
|
for (const [key, parts] of byKey) {
|
||||||
|
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
|
||||||
|
if (parts.length === 1) einzeln.push(parts[0])
|
||||||
|
else gruppen.push({ group: key, parts })
|
||||||
|
}
|
||||||
|
return { gruppen, einzeln }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matches(movie, term) {
|
||||||
|
return (
|
||||||
|
movie.title.toLowerCase().includes(term) ||
|
||||||
|
(movie.original_title || '').toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKatalog() {
|
||||||
|
const movies = ref([])
|
||||||
|
const status = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [moviesRes, statusRes] = await Promise.all([
|
||||||
|
fetch('/api/movies'),
|
||||||
|
fetch('/api/status'),
|
||||||
|
])
|
||||||
|
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
|
||||||
|
movies.value = await moviesRes.json()
|
||||||
|
status.value = await statusRes.json()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||||
|
const canonMovies = computed(() =>
|
||||||
|
movies.value.filter((m) => m.canon_group !== null)
|
||||||
|
)
|
||||||
|
|
||||||
|
const average = computed(() => {
|
||||||
|
const rated = movies.value.filter((m) => m.vote_count)
|
||||||
|
if (!rated.length) return 0
|
||||||
|
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
|
||||||
|
})
|
||||||
|
|
||||||
|
function score(movie) {
|
||||||
|
const votes = movie.vote_count || 0
|
||||||
|
return (
|
||||||
|
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
|
||||||
|
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTS = {
|
||||||
|
random: (a, b) => zufall(a) - zufall(b),
|
||||||
|
rating: (a, b) => score(b) - score(a),
|
||||||
|
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||||
|
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||||
|
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortiereReihe(parts) {
|
||||||
|
const sorted = [...parts].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||||
|
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
|
||||||
|
)
|
||||||
|
// Die Karte startet beim stärksten Teil, danach sortiert auch die
|
||||||
|
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
|
||||||
|
let best = 0
|
||||||
|
sorted.forEach((part, i) => {
|
||||||
|
if (score(part) > score(sorted[best])) best = i
|
||||||
|
})
|
||||||
|
return { parts: sorted, best }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
|
||||||
|
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
|
||||||
|
// Jahr — hier zählt das Erscheinungsjahr.
|
||||||
|
const canonGroups = computed(() => {
|
||||||
|
const byGroup = new Map()
|
||||||
|
for (const movie of canonMovies.value) {
|
||||||
|
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
|
||||||
|
byGroup.get(movie.canon_group).push(movie)
|
||||||
|
}
|
||||||
|
return [...byGroup.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
|
||||||
|
const netflixEntries = computed(() => {
|
||||||
|
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
|
||||||
|
return [
|
||||||
|
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
|
||||||
|
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
movies,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
netflixMovies,
|
||||||
|
canonMovies,
|
||||||
|
canonGroups,
|
||||||
|
netflixEntries,
|
||||||
|
SORTS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Eine Liste mit tausenden Karten: gerendert wird nur der Anfang, der Rest
|
||||||
|
// kommt beim Scrollen nach. Dazu der Knopf, der wieder nach oben bringt.
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
|
||||||
|
// Bilder — der Browser meldet dann "Seite reagiert nicht".
|
||||||
|
const STAPEL = 100
|
||||||
|
|
||||||
|
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
|
||||||
|
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
|
||||||
|
const OBEN_DAUER = 450
|
||||||
|
|
||||||
|
export function useLangeListe({ liste, ansicht }) {
|
||||||
|
const gezeigt = ref(STAPEL)
|
||||||
|
const sichtbar = computed(() => liste.value.slice(0, gezeigt.value))
|
||||||
|
|
||||||
|
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
|
||||||
|
watch([liste, ansicht], () => {
|
||||||
|
gezeigt.value = STAPEL
|
||||||
|
})
|
||||||
|
|
||||||
|
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
|
||||||
|
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
|
||||||
|
const nachschub = ref(null)
|
||||||
|
const beobachter = new IntersectionObserver(
|
||||||
|
(eintraege) => {
|
||||||
|
if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
|
||||||
|
},
|
||||||
|
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
|
||||||
|
{ rootMargin: '600px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function mehrLaden() {
|
||||||
|
if (gezeigt.value >= liste.value.length) return
|
||||||
|
gezeigt.value += STAPEL
|
||||||
|
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
|
||||||
|
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
|
||||||
|
// käme sonst nie ein zweiter Stapel.
|
||||||
|
await nextTick()
|
||||||
|
const el = nachschub.value
|
||||||
|
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
|
||||||
|
// Wechsel neu anmelden
|
||||||
|
watch(nachschub, (el, vorher) => {
|
||||||
|
if (vorher) beobachter.unobserve(vorher)
|
||||||
|
if (el) beobachter.observe(el)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
||||||
|
const obenKnopf = ref(false)
|
||||||
|
|
||||||
|
function beimScrollen() {
|
||||||
|
obenKnopf.value = window.scrollY > 800
|
||||||
|
}
|
||||||
|
|
||||||
|
function nachOben() {
|
||||||
|
const start = window.scrollY
|
||||||
|
const beginn = performance.now()
|
||||||
|
|
||||||
|
function schritt(jetzt) {
|
||||||
|
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
|
||||||
|
// Sanft an- und auslaufend
|
||||||
|
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
|
||||||
|
window.scrollTo(0, start * (1 - p))
|
||||||
|
if (t < 1) requestAnimationFrame(schritt)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(schritt)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('scroll', beimScrollen, { passive: true }))
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', beimScrollen)
|
||||||
|
beobachter.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { gezeigt, sichtbar, nachschub, obenKnopf, nachOben }
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Wer ich bin und was an mir hängt: die Runde, meine Sammlung, mein
|
||||||
|
// Vorschlag, die gesehenen Filme und die Wünsche. Alles wird beim Start
|
||||||
|
// geladen und über den Live-Kanal frisch gehalten.
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { abonnieren, neuVerbinden } from '../live'
|
||||||
|
|
||||||
|
async function hole(url) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
return resp.ok ? await resp.json() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMeins({ host }) {
|
||||||
|
// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus.
|
||||||
|
// Den Namen setzt danach der Server an Vorschläge und Wertungen.
|
||||||
|
const runde = ref([])
|
||||||
|
const ich = ref(null)
|
||||||
|
|
||||||
|
async function ladeRunde() {
|
||||||
|
const daten = await hole('/api/users')
|
||||||
|
if (!daten) return
|
||||||
|
runde.value = daten.items
|
||||||
|
ich.value = daten.mine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gibt zurück, ob der Wechsel durchging — bei hinterlegtem Anmeldefilm
|
||||||
|
// lehnt der Server ohne die richtige movie_id ab
|
||||||
|
async function michSetzen(id, movieId = null) {
|
||||||
|
const resp = await fetch('/api/users/waehlen', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: id ?? null, movie_id: movieId }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) return false
|
||||||
|
neuVerbinden()
|
||||||
|
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
|
||||||
|
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
|
||||||
|
await ladeRunde()
|
||||||
|
ladeVorschlag()
|
||||||
|
ladeMerkliste()
|
||||||
|
ladeGesehen()
|
||||||
|
ladeWuensche()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
|
||||||
|
// ob ein Film schon lief
|
||||||
|
const gesehen = ref([])
|
||||||
|
|
||||||
|
async function ladeGesehen() {
|
||||||
|
gesehen.value = (await hole('/api/watched')) ?? gesehen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
|
||||||
|
const gesehenIds = computed(() => new Set(gesehen.value.map((e) => e.movie_id)))
|
||||||
|
|
||||||
|
// Ausgeblendete Filme zählen für Gäste nicht mit
|
||||||
|
const gesehenZahl = computed(() => gesehen.value.filter((e) => host.value || !e.hidden).length)
|
||||||
|
|
||||||
|
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen —
|
||||||
|
// und alle Filme auf der Bahn, damit die Karten es zeigen können
|
||||||
|
const meinVorschlag = ref(null)
|
||||||
|
const aufDemRad = ref([])
|
||||||
|
|
||||||
|
async function ladeVorschlag() {
|
||||||
|
const daten = await hole('/api/suggestions')
|
||||||
|
if (!daten) {
|
||||||
|
meinVorschlag.value = null
|
||||||
|
aufDemRad.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
|
||||||
|
aufDemRad.value = daten.items.map((i) => i.movie_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
|
||||||
|
const merkliste = ref([])
|
||||||
|
|
||||||
|
async function ladeMerkliste() {
|
||||||
|
merkliste.value = (await hole('/api/wishlist')) ?? merkliste.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was der Seite noch fehlt
|
||||||
|
const wuensche = ref([])
|
||||||
|
|
||||||
|
async function ladeWuensche() {
|
||||||
|
wuensche.value = (await hole('/api/features')) ?? wuensche.value
|
||||||
|
}
|
||||||
|
|
||||||
|
let abbestellen = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ladeVorschlag()
|
||||||
|
ladeGesehen()
|
||||||
|
ladeRunde()
|
||||||
|
ladeMerkliste()
|
||||||
|
ladeWuensche()
|
||||||
|
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
|
||||||
|
abbestellen = abonnieren((nachricht) => {
|
||||||
|
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
|
||||||
|
else if (nachricht.type === 'users') ladeRunde()
|
||||||
|
else if (nachricht.type === 'features') ladeWuensche()
|
||||||
|
// Punkte und Level hängen an Wertungen, Kommentaren und Herzen —
|
||||||
|
// die Runde trägt sie, also mit nachladen
|
||||||
|
if (nachricht.type === 'watched') ladeRunde()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => abbestellen?.())
|
||||||
|
|
||||||
|
return {
|
||||||
|
runde,
|
||||||
|
ich,
|
||||||
|
michSetzen,
|
||||||
|
gesehen,
|
||||||
|
gesehenIds,
|
||||||
|
gesehenZahl,
|
||||||
|
ladeGesehen,
|
||||||
|
meinVorschlag,
|
||||||
|
aufDemRad,
|
||||||
|
ladeVorschlag,
|
||||||
|
merkliste,
|
||||||
|
ladeMerkliste,
|
||||||
|
wuensche,
|
||||||
|
ladeWuensche,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus Postern
|
||||||
|
// rast unter der Marke durch und bremst auf dem Gewinner aus.
|
||||||
|
//
|
||||||
|
// Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn rast los und kriecht am
|
||||||
|
// Ende). Jedes zweite Mal bricht danach die Halterung: kurze Ruhe, dann
|
||||||
|
// rollt die Bahn ein paar Poster zurück. Der Server nennt die Gesamtdauer
|
||||||
|
// und die Halte, die Phasenlängen stehen hier — Ruhe plus Rückfahrt müssen
|
||||||
|
// zu BRUCH_DAUER im Backend passen.
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { anlaufTon, fanfare, knacken, tick } from '../klang'
|
||||||
|
|
||||||
|
const ANLAUF = 1.0
|
||||||
|
const BRUCH_RUHE = 1.2
|
||||||
|
const BRUCH_FAHRT = 2.5
|
||||||
|
// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
|
||||||
|
const ENTHUELLUNG = 0.6
|
||||||
|
|
||||||
|
// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
|
||||||
|
const KURVEN = {
|
||||||
|
fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
|
||||||
|
bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf dem Handy sind die Poster kleiner, sonst passen keine drei ins Bild
|
||||||
|
const MASSE = {
|
||||||
|
breit: { kachel: 220, luecke: 10 },
|
||||||
|
schmal: { kachel: 140, luecke: 8 },
|
||||||
|
}
|
||||||
|
const SCHMAL = '(max-width: 640px)'
|
||||||
|
|
||||||
|
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
||||||
|
// verschwimmt die Marke mit dem Rand
|
||||||
|
export const FARBEN = [
|
||||||
|
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
||||||
|
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function useZiehung({ items }) {
|
||||||
|
const schmal = ref(window.matchMedia(SCHMAL).matches)
|
||||||
|
const masse = computed(() => {
|
||||||
|
const m = schmal.value ? MASSE.schmal : MASSE.breit
|
||||||
|
return { ...m, schritt: m.kachel + m.luecke }
|
||||||
|
})
|
||||||
|
|
||||||
|
const ergebnis = ref(null)
|
||||||
|
const dreht = ref(false)
|
||||||
|
const gewinner = ref(null)
|
||||||
|
const dauer = ref(0)
|
||||||
|
const x = ref(0)
|
||||||
|
const anlauf = ref(false)
|
||||||
|
const bruch = ref(false)
|
||||||
|
const kurve = ref(KURVEN.fahrt)
|
||||||
|
const bahn = ref(null)
|
||||||
|
let timer = null
|
||||||
|
let enthuellung = null
|
||||||
|
|
||||||
|
// Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
|
||||||
|
// Zielkachel unter der Marke, egal wie breit das Fenster ist.
|
||||||
|
function stoppX(stopp) {
|
||||||
|
const { kachel, schritt } = masse.value
|
||||||
|
return -(stopp.position * schritt + kachel / 2 + stopp.versatz * kachel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor der ersten Ziehung stehen die Vorschläge einfach in einer Reihe
|
||||||
|
function ruheX() {
|
||||||
|
const { luecke, schritt } = masse.value
|
||||||
|
return -(items.value.length * schritt - luecke) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function fahren(sekunden, k, ziel) {
|
||||||
|
kurve.value = k
|
||||||
|
dauer.value = sekunden
|
||||||
|
x.value = ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruhen() {
|
||||||
|
if (!ergebnis.value) fahren(0, KURVEN.fahrt, ruheX())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest die laufende Position aus dem Übergang und tickt bei jedem
|
||||||
|
// Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
|
||||||
|
let zuletzt = -1
|
||||||
|
let letzteX = 0
|
||||||
|
let letzteZeit = 0
|
||||||
|
|
||||||
|
function ticken(zeit) {
|
||||||
|
if (!dreht.value || !bahn.value) return
|
||||||
|
const { luecke, schritt } = masse.value
|
||||||
|
const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
|
||||||
|
const idx = Math.floor((-m.m41 + luecke / 2) / schritt)
|
||||||
|
const dt = (zeit - letzteZeit) / 1000
|
||||||
|
const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
|
||||||
|
letzteX = m.m41
|
||||||
|
letzteZeit = zeit
|
||||||
|
if (zuletzt === -1) {
|
||||||
|
// Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
|
||||||
|
zuletzt = idx
|
||||||
|
} else if (idx !== zuletzt) {
|
||||||
|
zuletzt = idx
|
||||||
|
tick(tempo)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(ticken)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticksStarten() {
|
||||||
|
zuletzt = -1
|
||||||
|
letzteZeit = 0
|
||||||
|
requestAnimationFrame(ticken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
|
||||||
|
// hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
|
||||||
|
function phasen(erg) {
|
||||||
|
const erst = erg.stopps[0]
|
||||||
|
const ende = erg.stopps.at(-1)
|
||||||
|
const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
|
||||||
|
const fahrt = erg.dauer - ANLAUF - extra
|
||||||
|
const liste = [
|
||||||
|
{
|
||||||
|
d: ANLAUF,
|
||||||
|
start(rest) {
|
||||||
|
anlauf.value = true
|
||||||
|
if (rest > 0) anlaufTon(rest)
|
||||||
|
},
|
||||||
|
ende() {
|
||||||
|
anlauf.value = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
d: fahrt,
|
||||||
|
start(rest) {
|
||||||
|
fahren(rest, KURVEN.fahrt, stoppX(erst))
|
||||||
|
ticksStarten()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (erg.stopps.length > 1) {
|
||||||
|
liste.push(
|
||||||
|
{ d: BRUCH_RUHE },
|
||||||
|
{
|
||||||
|
d: BRUCH_FAHRT,
|
||||||
|
start(rest) {
|
||||||
|
bruch.value = true
|
||||||
|
if (rest > 0) knacken()
|
||||||
|
fahren(rest, KURVEN.bruch, stoppX(ende))
|
||||||
|
},
|
||||||
|
ende() {
|
||||||
|
bruch.value = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
function ablauf(erg, liste, i, uebersprungen) {
|
||||||
|
if (i >= liste.length) {
|
||||||
|
dreht.value = false
|
||||||
|
// Erst Stille, dann der Glow
|
||||||
|
enthuellung = window.setTimeout(() => {
|
||||||
|
gewinner.value = erg.gewinner
|
||||||
|
fanfare()
|
||||||
|
}, ENTHUELLUNG * 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ph = liste[i]
|
||||||
|
if (uebersprungen >= ph.d) {
|
||||||
|
// Schon vorbei: nur den Endzustand setzen
|
||||||
|
ph.start?.(0)
|
||||||
|
ph.ende?.()
|
||||||
|
ablauf(erg, liste, i + 1, uebersprungen - ph.d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rest = ph.d - uebersprungen
|
||||||
|
ph.start?.(rest)
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
ph.ende?.()
|
||||||
|
ablauf(erg, liste, i + 1, 0)
|
||||||
|
}, rest * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function abbrechen() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
clearTimeout(enthuellung)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abspielen(erg, uebersprungen = 0) {
|
||||||
|
abbrechen()
|
||||||
|
gewinner.value = null
|
||||||
|
ergebnis.value = erg
|
||||||
|
dreht.value = true
|
||||||
|
|
||||||
|
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
|
||||||
|
// Ergebnis aus los
|
||||||
|
fahren(0, KURVEN.fahrt, -3 * masse.value.schritt)
|
||||||
|
await nextTick()
|
||||||
|
void bahn.value?.offsetWidth
|
||||||
|
|
||||||
|
ablauf(erg, phasen(erg), 0, uebersprungen)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufendenDrehHolen() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/spin')
|
||||||
|
if (!resp.ok) return
|
||||||
|
const daten = await resp.json()
|
||||||
|
if (!daten.result) return
|
||||||
|
if (daten.running) {
|
||||||
|
abspielen(daten.result, daten.elapsed)
|
||||||
|
} else {
|
||||||
|
// Ziehung ist durch: Bahn ohne Animation auf die Endstellung setzen,
|
||||||
|
// sonst steht die Marke woanders als die Gewinnerkarte
|
||||||
|
ergebnis.value = daten.result
|
||||||
|
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
|
||||||
|
gewinner.value = daten.result.gewinner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* dann eben ohne */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zurueckgesetzt() {
|
||||||
|
abbrechen()
|
||||||
|
anlauf.value = false
|
||||||
|
bruch.value = false
|
||||||
|
ergebnis.value = null
|
||||||
|
gewinner.value = null
|
||||||
|
dreht.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
|
||||||
|
const kacheln = computed(() => {
|
||||||
|
const erg = ergebnis.value
|
||||||
|
if (erg) {
|
||||||
|
const ziel = erg.stopps.at(-1).position
|
||||||
|
return erg.streifen.map((i, k) => ({
|
||||||
|
key: k,
|
||||||
|
film: erg.karten[i],
|
||||||
|
farbe: FARBEN[i % FARBEN.length],
|
||||||
|
treffer: k === ziel,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return items.value.map((item, i) => ({
|
||||||
|
key: i,
|
||||||
|
film: item,
|
||||||
|
farbe: FARBEN[i % FARBEN.length],
|
||||||
|
treffer: false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dreht jemand das Handy, ändern sich die Maße — die Bahn rückt nach
|
||||||
|
const medien = window.matchMedia(SCHMAL)
|
||||||
|
const beiWechsel = (e) => {
|
||||||
|
schmal.value = e.matches
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(schmal, () => {
|
||||||
|
if (dreht.value) return
|
||||||
|
if (ergebnis.value) fahren(0, KURVEN.fahrt, stoppX(ergebnis.value.stopps.at(-1)))
|
||||||
|
else ruhen()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => medien.addEventListener('change', beiWechsel))
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
abbrechen()
|
||||||
|
medien.removeEventListener('change', beiWechsel)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
masse,
|
||||||
|
ergebnis,
|
||||||
|
dreht,
|
||||||
|
gewinner,
|
||||||
|
dauer,
|
||||||
|
x,
|
||||||
|
anlauf,
|
||||||
|
bruch,
|
||||||
|
kurve,
|
||||||
|
bahn,
|
||||||
|
kacheln,
|
||||||
|
ruhen,
|
||||||
|
abspielen,
|
||||||
|
laufendenDrehHolen,
|
||||||
|
zurueckgesetzt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
|
|||||||
return raus
|
return raus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Filter der Leiste auf einen Treffer anwenden, den TMDB schon
|
||||||
|
// geliefert hat — ein Regler auf Anschlag schneidet nichts weg
|
||||||
|
export function passtZurLeiste(movie, filter) {
|
||||||
|
const g = filter.grenzen || {}
|
||||||
|
const drin = (werte, wert, key) => {
|
||||||
|
if (!werte || wert == null) return true
|
||||||
|
const grenze = g[key]
|
||||||
|
if (!grenze) return true
|
||||||
|
if (werte[0] > grenze.min && wert < werte[0]) return false
|
||||||
|
if (werte[1] < grenze.max && wert > werte[1]) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const genres = movie.genres || []
|
||||||
|
if ((filter.include || []).length && !genres.some((x) => filter.include.includes(x))) return false
|
||||||
|
if ((filter.exclude || []).some((x) => genres.includes(x))) return false
|
||||||
|
return (
|
||||||
|
drin(filter.stimmen, movie.vote_count, 'vote_count') &&
|
||||||
|
drin(filter.dauer, movie.runtime, 'runtime') &&
|
||||||
|
drin(filter.jahr, movie.year, 'year') &&
|
||||||
|
drin(filter.note, movie.vote_average, 'vote_average')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function filterParameter(filter) {
|
export function filterParameter(filter) {
|
||||||
return {
|
return {
|
||||||
sort: filter.sort || 'rating',
|
sort: filter.sort || 'rating',
|
||||||
|
...(filter.anzahl ? { limit: filter.anzahl } : {}),
|
||||||
include: (filter.include || []).join(','),
|
include: (filter.include || []).join(','),
|
||||||
exclude: (filter.exclude || []).join(','),
|
exclude: (filter.exclude || []).join(','),
|
||||||
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Die Geräusche der Ziehung: ein Tick je Poster, das unter der Marke
|
||||||
|
// durchläuft, ein Aufladen im Anlauf, ein Knacken beim Bruch und ein Klang
|
||||||
|
// zur Enthüllung. Alles synthetisch, keine Dateien.
|
||||||
|
//
|
||||||
|
// Der Browser gibt Ton erst nach einer Eingabe frei — wer nur zuschaut und
|
||||||
|
// nie geklickt hat, hört nichts, sieht aber alles.
|
||||||
|
|
||||||
|
let audio = null
|
||||||
|
|
||||||
|
export function klang() {
|
||||||
|
if (!audio) audio = new AudioContext()
|
||||||
|
if (audio.state === 'suspended') audio.resume()
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
|
function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
|
||||||
|
const a = klang()
|
||||||
|
const t = a.currentTime + start
|
||||||
|
const osc = a.createOscillator()
|
||||||
|
const gain = a.createGain()
|
||||||
|
osc.type = art
|
||||||
|
osc.frequency.value = frequenz
|
||||||
|
if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
|
||||||
|
gain.gain.setValueAtTime(lautstaerke, t)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
|
||||||
|
osc.connect(gain).connect(a.destination)
|
||||||
|
osc.start(t)
|
||||||
|
osc.stop(t + dauer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
|
||||||
|
// man hört das Bremsen.
|
||||||
|
export function tick(tempo) {
|
||||||
|
const a = Math.min(1, tempo / 3000)
|
||||||
|
ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anlaufTon(sekunden) {
|
||||||
|
ton(150, sekunden, 0.08, 'sine', 0, 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function knacken() {
|
||||||
|
ton(120, 0.12, 0.25, 'square')
|
||||||
|
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanfare() {
|
||||||
|
ton(660, 0.5, 0.12, 'sine')
|
||||||
|
ton(990, 0.7, 0.1, 'sine', 0.12)
|
||||||
|
}
|
||||||
+19
-8
@@ -4,15 +4,26 @@
|
|||||||
let quelle = null
|
let quelle = null
|
||||||
const hoerer = new Set()
|
const hoerer = new Set()
|
||||||
|
|
||||||
|
function oeffnen() {
|
||||||
|
quelle = new EventSource('/api/events')
|
||||||
|
quelle.onmessage = (ereignis) => {
|
||||||
|
const nachricht = JSON.parse(ereignis.data)
|
||||||
|
hoerer.forEach((h) => h(nachricht))
|
||||||
|
}
|
||||||
|
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||||
|
}
|
||||||
|
|
||||||
export function abonnieren(fn) {
|
export function abonnieren(fn) {
|
||||||
hoerer.add(fn)
|
hoerer.add(fn)
|
||||||
if (!quelle) {
|
if (!quelle) oeffnen()
|
||||||
quelle = new EventSource('/api/events')
|
|
||||||
quelle.onmessage = (ereignis) => {
|
|
||||||
const nachricht = JSON.parse(ereignis.data)
|
|
||||||
hoerer.forEach((h) => h(nachricht))
|
|
||||||
}
|
|
||||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
|
||||||
}
|
|
||||||
return () => hoerer.delete(fn)
|
return () => hoerer.delete(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der Server merkt sich je Verbindung die Sitzung — daran hängt der
|
||||||
|
// grüne Punkt. In einem frischen Browser steht die Verbindung, bevor
|
||||||
|
// das Cookie da ist. Nach dem Namenswechsel darum einmal neu verbinden.
|
||||||
|
export function neuVerbinden() {
|
||||||
|
if (!quelle) return
|
||||||
|
quelle.close()
|
||||||
|
oeffnen()
|
||||||
|
}
|
||||||
@@ -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('')
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
/* Kein Doppeltipp-Zoom, kein blauer Blitz beim Tippen */
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
@@ -49,3 +52,61 @@ select:focus {
|
|||||||
a {
|
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 */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@ hlsAlwaysRemux: yes
|
|||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
|
|
||||||
|
# Liefert Zeitbereiche der Aufnahme als MP4 — daraus schneidet die App
|
||||||
|
# die Clips. Nur intern erreichbar, nicht nach außen geöffnet.
|
||||||
|
playback: yes
|
||||||
|
playbackAddress: :9996
|
||||||
|
|
||||||
|
# Über die API löscht der Host alte Aufnahmen — Segment für Segment.
|
||||||
|
# Auch nur intern erreichbar; Zugang hat nur der Sender-Nutzer unten.
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
|
||||||
# Nicht gebraucht, also zu
|
# Nicht gebraucht, also zu
|
||||||
webrtc: no
|
webrtc: no
|
||||||
rtsp: no
|
rtsp: no
|
||||||
@@ -34,11 +44,22 @@ authInternalUsers:
|
|||||||
ips: []
|
ips: []
|
||||||
permissions:
|
permissions:
|
||||||
- action: publish
|
- action: publish
|
||||||
|
- action: api
|
||||||
- user: any
|
- user: any
|
||||||
pass:
|
pass:
|
||||||
ips: []
|
ips: []
|
||||||
permissions:
|
permissions:
|
||||||
- action: read
|
- action: read
|
||||||
|
# Ausschnitte der Aufnahme holen — der Port ist nur intern erreichbar
|
||||||
|
- action: playback
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
filmabend:
|
filmabend:
|
||||||
|
# Der Abend wird mitgeschnitten, ohne Neukodierung, in 30-Sekunden-
|
||||||
|
# Segmenten. Kurze Segmente, damit ein Clip kurz nach der Marke schon
|
||||||
|
# geschnitten werden kann. Gelöscht wird nichts von selbst.
|
||||||
|
record: yes
|
||||||
|
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||||
|
recordFormat: fmp4
|
||||||
|
recordSegmentDuration: 30s
|
||||||
|
recordDeleteAfter: 0s
|
||||||
+2
-1
@@ -28,7 +28,8 @@ if ! timeout 3 bash -c "</dev/tcp/${ZIEL}/1935" 2>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
scripts/ton.sh auf
|
# Der Ton geht nur in den Stream. MITHOEREN=1 legt ihn zusätzlich auf dein Gerät
|
||||||
|
scripts/ton.sh auf ${MITHOEREN:+mithoeren}
|
||||||
|
|
||||||
mkdir -p "$P" "$BASIS/scenes"
|
mkdir -p "$P" "$BASIS/scenes"
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -6,8 +6,10 @@
|
|||||||
# nach fünf Minuten sind es Sekunden. Eine Null-Senke hängt an der Systemuhr
|
# nach fünf Minuten sind es Sekunden. Eine Null-Senke hängt an der Systemuhr
|
||||||
# und liefert 48 kHz, also genau das, was der Encoder erwartet.
|
# und liefert 48 kHz, also genau das, was der Encoder erwartet.
|
||||||
#
|
#
|
||||||
# ton.sh auf Senke anlegen, zum Standard machen, Mithören einrichten
|
# ton.sh auf Senke anlegen und zum Standard machen. Der Ton
|
||||||
# ton.sh ab Standard zurückstellen, Senke entfernen
|
# geht nur in den Stream, der Rechner bleibt still.
|
||||||
|
# ton.sh auf mithoeren zusätzlich zurück auf dein gewohntes Gerät
|
||||||
|
# ton.sh ab Standard zurückstellen, Senke entfernen
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SENKE="filmabend"
|
SENKE="filmabend"
|
||||||
@@ -56,6 +58,20 @@ auf)
|
|||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Der Regelfall: der Ton geht nur in den Stream, hier bleibt es still.
|
||||||
|
# Am Pegel ändert das nichts — PipeWire lässt die Monitor-Ports vom Regler
|
||||||
|
# der Senke unberührt (monitor.channel-volumes ist aus), OBS bekommt also
|
||||||
|
# immer den vollen Ton. Genau deshalb macht Stummschalten allein auch nicht
|
||||||
|
# leise: das Mithören unten hängt am selben Monitor.
|
||||||
|
if [ "${2:-}" != "mithoeren" ]; then
|
||||||
|
# Eine Brücke aus einem früheren Lauf spielte sonst weiter auf die
|
||||||
|
# Lautsprecher — die Senke allein macht noch nicht still
|
||||||
|
pkill -f "node.name=filmabend-mithoeren" 2>/dev/null || true
|
||||||
|
wpctl set-default "$(id_von "$SENKE")"
|
||||||
|
echo "Ton geht nur in den Stream, $ZURUECK bleibt still."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Damit du selbst weiter über dein gewohntes Gerät hörst.
|
# Damit du selbst weiter über dein gewohntes Gerät hörst.
|
||||||
# setsid, sonst nimmt der Prozess das Ende dieser Shell mit und du
|
# setsid, sonst nimmt der Prozess das Ende dieser Shell mit und du
|
||||||
# hörst mitten im Film nichts mehr.
|
# hörst mitten im Film nichts mehr.
|
||||||
@@ -103,7 +119,7 @@ ab)
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "Aufruf: ton.sh auf|ab"
|
echo "Aufruf: ton.sh auf [mithoeren] | ab"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
Reference in new issue
Block a user