Benutzer, Sammlungen, Wünsche und zwei Kartenansichten
Die Runde ist jetzt benannt. Der Host pflegt die Namensliste im neuen Reiter "Verwaltung", alle anderen wählen sich oben rechts einen aus. Der Name landet serverseitig an Vorschlägen und Wertungen, statt aus einem Textfeld zu kommen, in das jeder schreiben konnte, was er wollte. Dazu zwei Sammelstellen, beide nach Person gruppiert und für alle sichtbar, aber nur selbst bearbeitbar: - "Sammlungen": Filme, die man sehen möchte. Getrennt vom Rad-Vorschlag, von dem es weiterhin einen je Person gibt. - "Wünsche": was der Seite noch fehlt. Der Host darf hier alle Einträge ändern und löschen. Die Filmliste hat jetzt zwei Ansichten. Kompakt ist die gewohnte Karte, Details stellt Cover, Text und vier Szenenbilder nebeneinander — eine Karte so breit wie vier kompakte oder die halbe Reihe, je nachdem was zuerst zutrifft. Reihen und Einzelfilme sind dabei exakt gleich hoch; die Blätterleiste liegt dafür über dem Cover statt darunter. Die Filmansicht nutzt die Breite: links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder. Ein Klick zeigt ein Bild in voller Größe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
This commit is contained in:
80
backend/app/features.py
Normal file
80
backend/app/features.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Was der Seite noch fehlt.
|
||||
|
||||
Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche,
|
||||
ändern darf jeder nur die eigenen — wie bei den Filmsammlungen.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS features (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
MAX_LAENGE = 500
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def alle(session_id):
|
||||
"""Alle Wünsche, jeder mit dem Namen seines Verfassers."""
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT f.*, u.name FROM features f "
|
||||
"LEFT JOIN user_sessions s ON s.session_id = f.session_id "
|
||||
"LEFT JOIN users u ON u.id = s.user_id "
|
||||
"ORDER BY f.created_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
**{k: v for k, v in dict(r).items() if k != "session_id"},
|
||||
"mine": r["session_id"] == session_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def anlegen(session_id, text):
|
||||
"""Gibt None zurück, wenn nichts dasteht."""
|
||||
text = (text or "").strip()[:MAX_LAENGE]
|
||||
if not text:
|
||||
return None
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
text,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
return alle(session_id)
|
||||
|
||||
|
||||
def aendern(feature_id, text):
|
||||
"""Der Host räumt auf: Tippfehler, Dopplungen, unklare Formulierungen."""
|
||||
text = (text or "").strip()[:MAX_LAENGE]
|
||||
if not text:
|
||||
return 0
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"UPDATE features SET text = ? WHERE id = ?", (text, feature_id)
|
||||
).rowcount
|
||||
|
||||
|
||||
def loeschen(feature_id):
|
||||
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem
|
||||
Verfasser und dem Host."""
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"DELETE FROM features WHERE id = ?", (feature_id,)
|
||||
).rowcount
|
||||
@@ -9,7 +9,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db, events, search, streamproxy, suggestions, sync, watched
|
||||
from . import (
|
||||
db,
|
||||
events,
|
||||
features,
|
||||
search,
|
||||
streamproxy,
|
||||
suggestions,
|
||||
sync,
|
||||
users,
|
||||
watched,
|
||||
wishlist,
|
||||
)
|
||||
from .config import IMAGE_BASE, STATIC_DIR
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
@@ -71,6 +82,9 @@ async def startup():
|
||||
db.init()
|
||||
suggestions.init()
|
||||
watched.init()
|
||||
users.init()
|
||||
wishlist.init()
|
||||
features.init()
|
||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||
scheduler.start()
|
||||
if not db.get_meta("last_sync"):
|
||||
@@ -227,13 +241,12 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
|
||||
async def vorschlagen(
|
||||
response: Response,
|
||||
movie_id: int = Body(..., embed=True),
|
||||
name: str | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
movie = await film_holen(movie_id)
|
||||
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
||||
eintrag = suggestions.ersetzen(sid, movie, name)
|
||||
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
|
||||
events.senden("suggestions")
|
||||
return {k: v for k, v in eintrag.items() if k != "session_id"}
|
||||
|
||||
@@ -258,6 +271,110 @@ async def film_holen(movie_id: int):
|
||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||
|
||||
|
||||
@app.get("/api/features")
|
||||
def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return features.alle(sid)
|
||||
|
||||
|
||||
@app.post("/api/features")
|
||||
def wunsch_anlegen(
|
||||
response: Response,
|
||||
text: str = Body(..., embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
liste = features.anlegen(sid, text)
|
||||
if liste is None:
|
||||
raise HTTPException(status_code=400, detail="Kein Text")
|
||||
events.senden("features")
|
||||
return liste
|
||||
|
||||
|
||||
@app.patch("/api/features/{feature_id}")
|
||||
def wunsch_aendern(feature_id: int, text: str = Body(..., embed=True)):
|
||||
if not features.aendern(feature_id, text):
|
||||
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
|
||||
events.senden("features")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/features/{feature_id}")
|
||||
def wunsch_loeschen(feature_id: int):
|
||||
entfernt = features.loeschen(feature_id)
|
||||
if entfernt:
|
||||
events.senden("features")
|
||||
return {"removed": entfernt}
|
||||
|
||||
|
||||
@app.get("/api/wishlist")
|
||||
def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
"""Was diese Sitzung sich vorgemerkt hat."""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return wishlist.alle(sid)
|
||||
|
||||
|
||||
@app.post("/api/wishlist")
|
||||
async def merken(
|
||||
response: Response,
|
||||
movie_id: int = Body(..., embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return wishlist.merken(sid, await film_holen(movie_id))
|
||||
|
||||
|
||||
@app.delete("/api/wishlist/{movie_id}")
|
||||
def vergessen(
|
||||
movie_id: int,
|
||||
response: Response,
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return {"removed": wishlist.vergessen(sid, movie_id)}
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return {"items": users.alle(), "mine": users.aktuell(sid)}
|
||||
|
||||
|
||||
@app.post("/api/users")
|
||||
def name_anlegen(name: str = Body(..., embed=True)):
|
||||
"""Der Host erweitert die Runde."""
|
||||
benutzer = users.anlegen(name)
|
||||
if not benutzer:
|
||||
raise HTTPException(status_code=400, detail="Name leer oder schon vergeben")
|
||||
events.senden("users")
|
||||
return benutzer
|
||||
|
||||
|
||||
@app.delete("/api/users/{user_id}")
|
||||
def name_entfernen(user_id: int):
|
||||
if not users.entfernen(user_id):
|
||||
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
||||
events.senden("users")
|
||||
return {"removed": 1}
|
||||
|
||||
|
||||
@app.post("/api/users/waehlen")
|
||||
def name_waehlen(
|
||||
response: Response,
|
||||
user_id: int | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Wer bin ich? Ohne user_id wieder anonym."""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
if user_id is None:
|
||||
users.abwaehlen(sid)
|
||||
return {"mine": None}
|
||||
benutzer = users.waehlen(sid, user_id)
|
||||
if not benutzer:
|
||||
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
||||
return {"mine": benutzer}
|
||||
|
||||
|
||||
@app.get("/api/watched")
|
||||
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
@@ -303,12 +420,12 @@ def bewerten(
|
||||
watched_id: int,
|
||||
response: Response,
|
||||
stars: int = Body(..., embed=True),
|
||||
name: str | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
stars = max(1, min(watched.MAX_STERNE, stars))
|
||||
eintrag = watched.bewerten(watched_id, sid, stars, name)
|
||||
# Der Name kommt aus der Runde, nicht aus der Anfrage
|
||||
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
|
||||
if not eintrag:
|
||||
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
||||
events.senden("watched")
|
||||
|
||||
112
backend/app/users.py
Normal file
112
backend/app/users.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Die Runde: wer mitmacht, und wer gerade wer ist.
|
||||
|
||||
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
|
||||
Kein Passwort — für acht Freunde, die sich ohnehin über Discord hören, wäre
|
||||
das nur eine Hürde ohne Nutzen.
|
||||
|
||||
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
|
||||
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
|
||||
einem Textfeld, in das jeder schreiben kann, was er will.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
seit TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def alle():
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def anlegen(name):
|
||||
"""Gibt None zurück, wenn der Name schon vergeben ist."""
|
||||
name = (name or "").strip()[:40]
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (name, created_at) VALUES (?, ?)",
|
||||
(name, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
return von_name(name)
|
||||
|
||||
|
||||
def von_name(name):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def entfernen(user_id):
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM user_sessions WHERE user_id = ?", (user_id,))
|
||||
return conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount
|
||||
|
||||
|
||||
def waehlen(session_id, user_id):
|
||||
"""Gibt None zurück, wenn es den Namen nicht gibt."""
|
||||
with connect() as conn:
|
||||
da = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if not da:
|
||||
return None
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions (session_id, user_id, seit) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT (session_id) DO UPDATE SET "
|
||||
"user_id = excluded.user_id, seit = excluded.seit",
|
||||
(
|
||||
session_id,
|
||||
user_id,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
return dict(da)
|
||||
|
||||
|
||||
def abwaehlen(session_id):
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"DELETE FROM user_sessions WHERE session_id = ?", (session_id,)
|
||||
).rowcount
|
||||
|
||||
|
||||
def aktuell(session_id):
|
||||
"""Der Benutzer dieser Sitzung, oder None."""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT u.* FROM users u "
|
||||
"JOIN user_sessions s ON s.user_id = u.id "
|
||||
"WHERE s.session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def name_von(session_id):
|
||||
"""Nur der Name, für Vorschläge und Wertungen."""
|
||||
benutzer = aktuell(session_id)
|
||||
return benutzer["name"] if benutzer else None
|
||||
91
backend/app/wishlist.py
Normal file
91
backend/app/wishlist.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Was jeder für sich sehen möchte.
|
||||
|
||||
Alle sehen alle Sammlungen — man merkt so, worauf die anderen Lust haben.
|
||||
Ändern darf jeder nur die eigene. Der Vorschlag fürs Rad ist eine andere
|
||||
Sache: davon gibt es einen je Person, und er landet im Glücksrad.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS wishlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
movie_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
poster_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (session_id, movie_id)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def alle(session_id):
|
||||
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, u.name FROM wishlist w "
|
||||
"LEFT JOIN user_sessions s ON s.session_id = w.session_id "
|
||||
"LEFT JOIN users u ON u.id = s.user_id "
|
||||
"ORDER BY w.created_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
**{k: v for k, v in dict(r).items() if k != "session_id"},
|
||||
"mine": r["session_id"] == session_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def merken(session_id, movie):
|
||||
"""Ein zweites Mal merken ändert nichts."""
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO wishlist "
|
||||
"(session_id, movie_id, title, year, poster_path, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
movie["id"],
|
||||
movie["title"],
|
||||
movie.get("year"),
|
||||
movie.get("poster_path"),
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
return alle(session_id)
|
||||
|
||||
|
||||
def vergessen(session_id, movie_id):
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?",
|
||||
(session_id, movie_id),
|
||||
).rowcount
|
||||
|
||||
|
||||
def ids(session_id):
|
||||
"""movie_ids der gemerkten Filme, für die Detailansicht."""
|
||||
with connect() as conn:
|
||||
return [
|
||||
r["movie_id"]
|
||||
for r in conn.execute(
|
||||
"SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
]
|
||||
@@ -3,7 +3,11 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { abonnieren } from './live'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import MerklisteTab from './components/MerklisteTab.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import NamensWahl from './components/NamensWahl.vue'
|
||||
import VerwaltungTab from './components/VerwaltungTab.vue'
|
||||
import WuenscheTab from './components/WuenscheTab.vue'
|
||||
import SearchTab from './components/SearchTab.vue'
|
||||
import StreamTab from './components/StreamTab.vue'
|
||||
import WatchedTab from './components/WatchedTab.vue'
|
||||
@@ -16,10 +20,19 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('random')
|
||||
|
||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
||||
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
||||
|
||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
||||
const selected = ref(null)
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream']
|
||||
const TAB_IDS = [
|
||||
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche',
|
||||
'verwaltung', 'stream',
|
||||
]
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||
|
||||
@@ -200,9 +213,14 @@ const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
...(host.value
|
||||
? []
|
||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.length },
|
||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||
])
|
||||
|
||||
const rad = ref(null)
|
||||
@@ -227,16 +245,43 @@ const gesehenZahl = computed(
|
||||
|
||||
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
|
||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
||||
// Ein Name für alles: Vorschläge und Wertungen. Ohne Namen läuft es
|
||||
// anonym, das ist ausdrücklich erlaubt.
|
||||
const NAME_SCHLUESSEL = 'filmabend_name'
|
||||
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
|
||||
// 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)
|
||||
|
||||
watch(name, (wert) => localStorage.setItem(NAME_SCHLUESSEL, wert))
|
||||
async function michSetzen(id) {
|
||||
await fetch('/api/users/waehlen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id ?? null }),
|
||||
})
|
||||
ladeRunde()
|
||||
}
|
||||
|
||||
async function ladeRunde() {
|
||||
try {
|
||||
const resp = await fetch('/api/users')
|
||||
if (!resp.ok) return
|
||||
const daten = await resp.json()
|
||||
runde.value = daten.items
|
||||
ich.value = daten.mine
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_SCHLUESSEL = 'filmabend_host'
|
||||
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
|
||||
|
||||
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
|
||||
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
|
||||
watch(host, (an) => {
|
||||
// Verwaltung sieht nur der Host, Vorschläge nur die Gäste
|
||||
const weg = an ? 'merkliste' : 'verwaltung'
|
||||
if (activeTab.value === weg) activeTab.value = 'netflix'
|
||||
})
|
||||
|
||||
function tastatur(e) {
|
||||
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
||||
// je nach System ein anderes Zeichen
|
||||
@@ -253,6 +298,30 @@ const meinVorschlag = ref(null)
|
||||
// Alle Filme auf dem Rad, damit die Karten es zeigen können
|
||||
const aufDemRad = ref([])
|
||||
|
||||
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
|
||||
const merkliste = ref([])
|
||||
|
||||
// Was der Seite noch fehlt
|
||||
const wuensche = ref([])
|
||||
|
||||
async function ladeWuensche() {
|
||||
try {
|
||||
const resp = await fetch('/api/features')
|
||||
if (resp.ok) wuensche.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeMerkliste() {
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist')
|
||||
if (resp.ok) merkliste.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeVorschlag() {
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions')
|
||||
@@ -397,9 +466,14 @@ onMounted(() => {
|
||||
load()
|
||||
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()
|
||||
})
|
||||
window.addEventListener('keydown', tastatur)
|
||||
})
|
||||
@@ -411,7 +485,11 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
|
||||
<div class="page" :class="{
|
||||
breit: [
|
||||
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
].includes(activeTab),
|
||||
}">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@@ -423,14 +501,8 @@ onUnmounted(() => {
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
<input
|
||||
v-model.trim="name"
|
||||
type="text"
|
||||
class="name"
|
||||
placeholder="Dein Name"
|
||||
maxlength="40"
|
||||
/>
|
||||
<span v-if="host" class="host">Host</span>
|
||||
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
|
||||
<span v-else class="host">Host</span>
|
||||
</nav>
|
||||
|
||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||
@@ -446,11 +518,28 @@ onUnmounted(() => {
|
||||
<WatchedTab
|
||||
v-else-if="activeTab === 'watched'"
|
||||
:host="host"
|
||||
:name="name"
|
||||
@open="selected = $event"
|
||||
@changed="ladeGesehen"
|
||||
/>
|
||||
|
||||
<MerklisteTab
|
||||
v-else-if="activeTab === 'merkliste'"
|
||||
:items="merkliste"
|
||||
:ich="ich"
|
||||
@open="selected = $event"
|
||||
@changed="ladeMerkliste"
|
||||
/>
|
||||
|
||||
<WuenscheTab
|
||||
v-else-if="activeTab === 'wuensche'"
|
||||
:items="wuensche"
|
||||
:ich="ich"
|
||||
:host="host"
|
||||
@changed="ladeWuensche"
|
||||
/>
|
||||
|
||||
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" />
|
||||
|
||||
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||
|
||||
<div v-else class="layout">
|
||||
@@ -464,7 +553,7 @@ onUnmounted(() => {
|
||||
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="grid" :class="ansicht">
|
||||
<template
|
||||
v-for="entry in activeTab === 'netflix' ? visible : visibleCanon"
|
||||
:key="entry.group"
|
||||
@@ -473,6 +562,7 @@ onUnmounted(() => {
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<SeriesCard
|
||||
@@ -480,6 +570,7 @@ onUnmounted(() => {
|
||||
:parts="entry.parts"
|
||||
:start="entry.best"
|
||||
:auf-dem-rad="aufDemRad"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</template>
|
||||
@@ -490,6 +581,7 @@ onUnmounted(() => {
|
||||
v-if="genreOptions.length"
|
||||
v-model:search="search"
|
||||
v-model:sort="sort"
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ranges="ranges"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
@@ -510,8 +602,9 @@ onUnmounted(() => {
|
||||
@open="selected = $event"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:name="name"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
|
||||
@gemerkt="ladeMerkliste"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
/>
|
||||
@@ -557,15 +650,9 @@ onUnmounted(() => {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tabs .name {
|
||||
width: 11rem;
|
||||
margin: 0 0 0.35rem auto;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.host {
|
||||
align-self: center;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
@@ -629,6 +716,15 @@ onUnmounted(() => {
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
|
||||
oder die halbe Reihe, falls die schmaler ist. */
|
||||
.grid.details {
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||
);
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
|
||||
const search = defineModel('search', { type: String, default: '' })
|
||||
const sort = defineModel('sort', { type: String, default: 'random' })
|
||||
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
|
||||
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||
|
||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||
@@ -53,6 +54,23 @@ function beschriften(regler) {
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
|
||||
<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">
|
||||
@@ -237,4 +255,29 @@ h4 {
|
||||
border-color: #4a4a56;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
|
||||
189
frontend/src/components/MerklisteTab.vue
Normal file
189
frontend/src/components/MerklisteTab.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
ich: { type: Object, default: null },
|
||||
})
|
||||
|
||||
// Nach Person gruppiert, die eigene Sammlung zuerst
|
||||
const gruppen = computed(() => {
|
||||
const nach = new Map()
|
||||
for (const item of props.items) {
|
||||
const schluessel = item.mine ? '\u0000eigene' : item.name || 'Anonym'
|
||||
if (!nach.has(schluessel)) {
|
||||
nach.set(schluessel, {
|
||||
titel: item.mine
|
||||
? `${props.ich?.name || 'Anonym'} (Deine)`
|
||||
: item.name || 'Anonym',
|
||||
mine: item.mine,
|
||||
filme: [],
|
||||
})
|
||||
}
|
||||
nach.get(schluessel).filme.push(item)
|
||||
}
|
||||
return [...nach.values()].sort((a, b) => b.mine - a.mine)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open', 'changed'])
|
||||
|
||||
async function vergessen(item) {
|
||||
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="merkliste-tab">
|
||||
<p v-if="!items.length" class="note">
|
||||
Noch nichts gemerkt. Öffne einen Film und klicke auf „Merken“.
|
||||
</p>
|
||||
|
||||
<section v-for="gruppe in gruppen" :key="gruppe.titel">
|
||||
<h3>
|
||||
{{ gruppe.titel }}
|
||||
<span class="anzahl">{{ gruppe.filme.length }}</span>
|
||||
</h3>
|
||||
|
||||
<div class="grid">
|
||||
<article v-for="item in gruppe.filme" :key="item.id" class="karte">
|
||||
<button type="button" class="cover" @click="oeffnen(item)">
|
||||
<img
|
||||
v-if="posterUrl(item.poster_path, 'w342')"
|
||||
:src="posterUrl(item.poster_path, 'w342')"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="kein-cover">Kein Cover</span>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
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;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
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;
|
||||
letter-spacing: 0;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h4:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.jahr {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.weg {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.weg:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
aufDemRad: { type: Boolean, default: false },
|
||||
ansicht: { type: String, default: 'kompakt' },
|
||||
})
|
||||
|
||||
defineEmits(['open'])
|
||||
@@ -14,6 +15,11 @@ const rating = computed(() =>
|
||||
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
|
||||
)
|
||||
|
||||
// Drei reichen: mehr macht die Karte hoch, ohne mehr zu zeigen
|
||||
const shots = computed(() =>
|
||||
(props.movie.images || []).slice(0, 4).map((pfad) => backdropUrl(pfad, 'w300'))
|
||||
)
|
||||
|
||||
const meta = computed(() =>
|
||||
[props.movie.year || 'Jahr unbekannt', props.movie.runtime && `${props.movie.runtime} Min.`]
|
||||
.filter(Boolean)
|
||||
@@ -24,25 +30,108 @@ const meta = computed(() =>
|
||||
<template>
|
||||
<button
|
||||
class="card"
|
||||
:class="{ 'auf-dem-rad': aufDemRad }"
|
||||
:class="[ansicht, { 'auf-dem-rad': aufDemRad }]"
|
||||
type="button"
|
||||
@click="$emit('open', movie)"
|
||||
>
|
||||
<span v-if="aufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
|
||||
<div class="poster">
|
||||
<img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
<div class="haupt">
|
||||
<div class="poster">
|
||||
<img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<template v-if="ansicht === 'details'">
|
||||
<p v-if="movie.genres?.length" class="genres">
|
||||
{{ movie.genres.join(' · ') }}
|
||||
</p>
|
||||
<p v-if="movie.vote_count" class="stimmen">
|
||||
★ {{ rating }} aus {{ movie.vote_count.toLocaleString('de-DE') }} Stimmen
|
||||
</p>
|
||||
</template>
|
||||
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
||||
|
||||
<div v-if="ansicht === 'details' && shots.length" class="shots">
|
||||
<img
|
||||
v-for="(shot, i) in shots"
|
||||
:key="shot"
|
||||
:src="shot"
|
||||
:alt="`${movie.title} – Szenenbild ${i + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */
|
||||
.haupt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Details: vier Spalten — Cover, Text, dann zweimal zwei Bilder.
|
||||
.haupt löst sich auf, damit Cover und Text eigene Spalten sind. */
|
||||
.card.details {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(180px, 1fr) 340px;
|
||||
/* Feste Zeilenhöhe nach dem Cover: sonst sind Reihen höher als
|
||||
Einzelfilme, weil bei ihnen die Teil-Zeile dazukommt */
|
||||
grid-template-rows: 225px;
|
||||
gap: 0.9rem;
|
||||
align-items: stretch;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.card.details .haupt {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.card.details .poster {
|
||||
width: 150px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Feste 16:9 statt Streckung — sonst zieht ein langer Teaser die Bilder
|
||||
in die Länge */
|
||||
/* Zwei Spalten mit je zwei Bildern untereinander */
|
||||
.shots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shots img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Der Teaser bleibt auf vier Zeilen — sonst wird die Karte höher als die
|
||||
kompakte, und die soll das Maß bleiben */
|
||||
|
||||
.card.details .body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.genres,
|
||||
.stimmen {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.stimmen {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -105,8 +194,8 @@ const meta = computed(() =>
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
@@ -150,4 +239,9 @@ h2 {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.card.details .body,
|
||||
.card.details .shots {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,11 @@ const props = defineProps({
|
||||
series: { type: Array, default: () => [] },
|
||||
meinVorschlag: { type: Object, default: null },
|
||||
host: { type: Boolean, default: false },
|
||||
name: { type: String, default: '' },
|
||||
gesehen: { type: Boolean, default: false },
|
||||
gemerkt: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
|
||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit' })
|
||||
|
||||
@@ -19,6 +19,16 @@ const schonVorgeschlagen = computed(
|
||||
() => props.meinVorschlag?.movie_id === props.movie.id
|
||||
)
|
||||
|
||||
async function merkenUmschalten() {
|
||||
const weg = props.gemerkt
|
||||
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
||||
method: weg ? 'DELETE' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: weg ? undefined : JSON.stringify({ movie_id: props.movie.id }),
|
||||
})
|
||||
emit('gemerkt')
|
||||
}
|
||||
|
||||
async function zuruecknehmen() {
|
||||
vorschlagen.value = { zustand: 'laeuft' }
|
||||
try {
|
||||
@@ -37,11 +47,7 @@ async function abschicken() {
|
||||
const resp = await fetch('/api/suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
movie_id: props.movie.id,
|
||||
// Der Name steht oben rechts, einmal für alles
|
||||
name: props.name || null,
|
||||
}),
|
||||
body: JSON.stringify({ movie_id: props.movie.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
vorschlagen.value = { zustand: 'fertig' }
|
||||
@@ -73,6 +79,13 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const shots = computed(() =>
|
||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||
)
|
||||
|
||||
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
|
||||
const grossesBild = ref(null)
|
||||
|
||||
function bildZeigen(index) {
|
||||
grossesBild.value = backdropUrl((props.movie.images || [])[index], 'original')
|
||||
}
|
||||
const trailer = computed(() =>
|
||||
props.movie.trailer_key
|
||||
? `https://www.youtube-nocookie.com/embed/${props.movie.trailer_key}`
|
||||
@@ -83,7 +96,9 @@ const runtime = computed(() =>
|
||||
)
|
||||
|
||||
function onKey(event) {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
if (event.key !== 'Escape') return
|
||||
if (grossesBild.value) grossesBild.value = null
|
||||
else emit('close')
|
||||
}
|
||||
|
||||
function openPart(part) {
|
||||
@@ -126,7 +141,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||
<div class="links">
|
||||
<div class="spalte-links">
|
||||
<a
|
||||
v-if="movie.on_netflix"
|
||||
class="netflix"
|
||||
@@ -147,7 +162,10 @@ onUnmounted(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="vorschlag">
|
||||
<div v-if="!host" class="vorschlag">
|
||||
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
|
||||
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
|
||||
</button>
|
||||
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
|
||||
<span class="ok">Steht auf dem Rad.</span>
|
||||
<button
|
||||
@@ -195,6 +213,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="links">
|
||||
<section v-if="series.length">
|
||||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||||
<div class="series">
|
||||
@@ -223,9 +242,9 @@ onUnmounted(() => {
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="trailer">
|
||||
<h3>Trailer</h3>
|
||||
<section v-if="trailer" class="rechts">
|
||||
<div class="video">
|
||||
<iframe
|
||||
:src="trailer"
|
||||
@@ -235,21 +254,29 @@ onUnmounted(() => {
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<p v-else class="no-trailer">Für diesen Film liegt kein Trailer vor.</p>
|
||||
|
||||
<section v-if="shots.length">
|
||||
<h3>Bilder</h3>
|
||||
<section v-if="shots.length" class="shots-section">
|
||||
<div class="shots">
|
||||
<img
|
||||
<button
|
||||
v-for="(shot, index) in shots"
|
||||
:key="shot"
|
||||
:src="shot"
|
||||
:alt="`${movie.title} – Szenenbild ${index + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
type="button"
|
||||
class="shot"
|
||||
@click="bildZeigen(index)"
|
||||
>
|
||||
<img
|
||||
:src="shot"
|
||||
:alt="`${movie.title} – Szenenbild ${index + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||||
<img :src="grossesBild" :alt="movie.title" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -268,7 +295,11 @@ onUnmounted(() => {
|
||||
|
||||
.sheet {
|
||||
position: relative;
|
||||
width: min(880px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0 1.75rem;
|
||||
align-content: start;
|
||||
width: min(1700px, 100%);
|
||||
height: fit-content;
|
||||
padding: 1.75rem;
|
||||
background: var(--bg-soft);
|
||||
@@ -276,6 +307,35 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.sheet > .rechts {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.sheet > .shots-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.sheet {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sheet > .rechts,
|
||||
.sheet > .shots-section,
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
@@ -297,7 +357,8 @@ header {
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
width: 160px;
|
||||
align-self: flex-start;
|
||||
width: 130px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -391,6 +452,24 @@ h2 {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.merken {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.merken:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.merken.an {
|
||||
color: #6bbf7a;
|
||||
border-color: #6bbf7a;
|
||||
}
|
||||
|
||||
.gesehen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -516,8 +595,8 @@ h3 {
|
||||
|
||||
.shots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.shots img {
|
||||
@@ -539,11 +618,6 @@ h3 {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-trailer {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
@@ -557,4 +631,31 @@ h3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: block;
|
||||
padding: 0;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.shot:hover img {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.lupe {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
cursor: zoom-out;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.lupe img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
116
frontend/src/components/NamensWahl.vue
Normal file
116
frontend/src/components/NamensWahl.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
mine: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['waehlen'])
|
||||
|
||||
// Firefox zeichnet die Optionsliste eines <select> unter Linux im
|
||||
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
|
||||
const offen = ref(false)
|
||||
const halter = ref(null)
|
||||
|
||||
function waehlen(id) {
|
||||
offen.value = false
|
||||
emit('waehlen', id)
|
||||
}
|
||||
|
||||
function daneben(e) {
|
||||
if (halter.value && !halter.value.contains(e.target)) offen.value = false
|
||||
}
|
||||
|
||||
function escape(e) {
|
||||
if (e.key === 'Escape') offen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', daneben)
|
||||
document.addEventListener('keydown', escape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', daneben)
|
||||
document.removeEventListener('keydown', escape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="halter" class="wahl">
|
||||
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
|
||||
{{ mine ? mine.name : 'Anonym' }}
|
||||
</button>
|
||||
|
||||
<ul v-if="offen" class="menue">
|
||||
<li>
|
||||
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
|
||||
Anonym
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="u in items" :key="u.id">
|
||||
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u.id)">
|
||||
{{ u.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wahl {
|
||||
position: relative;
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.knopf {
|
||||
padding: 0.32rem 0.7rem;
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.knopf:hover,
|
||||
.knopf.offen {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.menue {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.3rem);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
min-width: 9rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.menue button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.menue button:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.menue button.aktiv {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
parts: { type: Array, required: true },
|
||||
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
|
||||
start: { type: Number, default: 0 },
|
||||
aufDemRad: { type: Array, default: () => [] },
|
||||
ansicht: { type: String, default: 'kompakt' },
|
||||
})
|
||||
|
||||
defineEmits(['open'])
|
||||
@@ -20,6 +21,10 @@ const rating = computed(() =>
|
||||
|
||||
const stehtAufDemRad = computed(() => props.aufDemRad.includes(current.value.id))
|
||||
|
||||
const shots = computed(() =>
|
||||
(current.value.images || []).slice(0, 4).map((pfad) => backdropUrl(pfad, 'w300'))
|
||||
)
|
||||
|
||||
const meta = computed(() =>
|
||||
[current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`]
|
||||
.filter(Boolean)
|
||||
@@ -33,35 +38,54 @@ function step(delta) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="card" :class="{ 'auf-dem-rad': stehtAufDemRad }">
|
||||
<article class="card" :class="[ansicht, { 'auf-dem-rad': stehtAufDemRad }]">
|
||||
<span v-if="stehtAufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
|
||||
<button class="poster" type="button" @click="$emit('open', current)">
|
||||
<img v-if="poster" :src="poster" :alt="current.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
</button>
|
||||
<div class="haupt">
|
||||
<button class="poster" type="button" @click="$emit('open', current)">
|
||||
<img v-if="poster" :src="poster" :alt="current.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
</button>
|
||||
|
||||
<div class="nav">
|
||||
<button type="button" aria-label="Vorheriger Teil" @click="step(-1)">‹</button>
|
||||
<div class="dots">
|
||||
<button
|
||||
v-for="(part, i) in parts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ on: i === index }"
|
||||
:title="part.title"
|
||||
@click="index = i"
|
||||
></button>
|
||||
<div class="nav">
|
||||
<button type="button" aria-label="Vorheriger Teil" @click="step(-1)">‹</button>
|
||||
<div class="dots">
|
||||
<button
|
||||
v-for="(part, i) in parts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ on: i === index }"
|
||||
:title="part.title"
|
||||
@click="index = i"
|
||||
></button>
|
||||
</div>
|
||||
<button type="button" aria-label="Nächster Teil" @click="step(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<template v-if="ansicht === 'details'">
|
||||
<p v-if="current.genres?.length" class="genres">
|
||||
{{ current.genres.join(' · ') }}
|
||||
</p>
|
||||
<p v-if="current.vote_count" class="stimmen">
|
||||
★ {{ rating }} aus {{ current.vote_count.toLocaleString('de-DE') }} Stimmen
|
||||
</p>
|
||||
</template>
|
||||
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
||||
</div>
|
||||
<button type="button" aria-label="Nächster Teil" @click="step(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
|
||||
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
||||
<div v-if="ansicht === 'details' && shots.length" class="shots">
|
||||
<img
|
||||
v-for="(shot, i) in shots"
|
||||
:key="shot"
|
||||
:src="shot"
|
||||
:alt="`${current.title} – Szenenbild ${i + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -108,8 +132,8 @@ function step(delta) {
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
@@ -125,14 +149,6 @@ function step(delta) {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.counter {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
|
||||
unterschiedlich weit nach unten */
|
||||
@@ -169,29 +185,37 @@ h2:hover {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Die Leiste liegt über dem unteren Cover-Rand. Der negative Abstand
|
||||
entspricht ihrer Höhe, damit Reihen genauso hoch sind wie Einzelfilme —
|
||||
sonst zieht eine Reihe die ganze Zeile mit in die Länge. */
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
margin: -2rem 0.4rem 0;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 0.5rem;
|
||||
/* Auf hellen Covern ging die Leiste sonst unter */
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav > button {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav > button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.dots {
|
||||
@@ -206,7 +230,7 @@ h2:hover {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
padding: 0;
|
||||
background: var(--line);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -215,7 +239,7 @@ h2:hover {
|
||||
}
|
||||
|
||||
.dot:hover {
|
||||
background: var(--muted);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.card.auf-dem-rad {
|
||||
@@ -237,4 +261,87 @@ h2:hover {
|
||||
background: #6bbf7a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.haupt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Details: links die gewohnte Karte, rechts die Bilder */
|
||||
/* Details: vier Spalten — Cover, Text, dann zweimal zwei Bilder.
|
||||
.haupt löst sich auf, damit Cover und Text eigene Spalten sind. */
|
||||
.card.details {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(180px, 1fr) 340px;
|
||||
/* Feste Zeilenhöhe nach dem Cover: sonst sind Reihen höher als
|
||||
Einzelfilme, weil bei ihnen die Teil-Zeile dazukommt */
|
||||
grid-template-rows: 225px;
|
||||
gap: 0.9rem;
|
||||
align-items: stretch;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.card.details .haupt {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.card.details .poster {
|
||||
grid-area: 1 / 1;
|
||||
width: 150px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Die Blätterleiste liegt über dem unteren Cover-Rand. In einer eigenen
|
||||
Zeile würde sie die Reihenkarten höher machen als die anderen. */
|
||||
.card.details .nav {
|
||||
grid-area: 1 / 1;
|
||||
align-self: end;
|
||||
margin: 0 0.4rem 0.4rem;
|
||||
}
|
||||
|
||||
.card.details .body {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
|
||||
.card.details .shots {
|
||||
grid-area: 1 / 3;
|
||||
}
|
||||
|
||||
.card.details .body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Der Teaser bleibt auf vier Zeilen — sonst wird die Karte höher als die
|
||||
kompakte, und die soll das Maß bleiben */
|
||||
|
||||
.genres,
|
||||
.stimmen {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.stimmen {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
/* Zwei Spalten mit je zwei Bildern untereinander */
|
||||
.shots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shots img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.card.details .body,
|
||||
.card.details .shots {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,37 @@ const lautstaerke = ref(1)
|
||||
const vollbild = ref(false)
|
||||
|
||||
let hls = null
|
||||
let neustart = null
|
||||
let wache = null
|
||||
|
||||
// Mehrere Fehler kurz hintereinander sollen nicht mehrere Timer anlegen
|
||||
function spaeterNeuLaden() {
|
||||
if (neustart) return
|
||||
neustart = window.setTimeout(() => {
|
||||
neustart = null
|
||||
hls?.startLoad()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// hls.js meldet nicht jeden Hänger als Fehler. Manchmal steht die Playlist,
|
||||
// aber es kommt kein Segment — dann bleibt der Player einfach stehen, ohne
|
||||
// dass irgendetwas davon erfährt. Wer die Seite offen hat, bevor gesendet
|
||||
// wird, sitzt sonst vor einem toten Bild.
|
||||
function wacheStarten() {
|
||||
let letzte = -1
|
||||
let stillstand = 0
|
||||
wache = window.setInterval(() => {
|
||||
const v = video.value
|
||||
if (!v || v.paused || zustand.value === 'bereit') return
|
||||
if (v.currentTime === letzte) stillstand++
|
||||
else stillstand = 0
|
||||
letzte = v.currentTime
|
||||
if (stillstand >= 5) {
|
||||
stillstand = 0
|
||||
hls?.startLoad()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Was der Server sendet, läuft. Wer anhält, fällt aus dem Gleichlauf und
|
||||
// müsste den Rückstand hinterher mit erhöhtem Tempo aufholen. Also lassen
|
||||
@@ -90,7 +121,7 @@ function verbinden() {
|
||||
if (daten.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
zustand.value = 'wartet'
|
||||
// Nicht sofort neu anfragen — sonst hämmert der Browser den Server
|
||||
window.setTimeout(() => hls?.startLoad(), 3000)
|
||||
spaeterNeuLaden()
|
||||
} else if (daten.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
hls.recoverMediaError()
|
||||
} else {
|
||||
@@ -99,6 +130,11 @@ function verbinden() {
|
||||
}
|
||||
})
|
||||
|
||||
// Kommen wieder Daten, ist die Wartemeldung überholt
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
||||
if (zustand.value === 'wartet') zustand.value = 'laeuft'
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
zustand.value = 'laeuft'
|
||||
video.value.play().catch(() => {
|
||||
@@ -126,6 +162,7 @@ function vollbildGewechselt() {
|
||||
|
||||
onMounted(() => {
|
||||
verbinden()
|
||||
wacheStarten()
|
||||
document.addEventListener('fullscreenchange', vollbildGewechselt)
|
||||
window.addEventListener('keydown', tasteAbfangen)
|
||||
})
|
||||
@@ -133,6 +170,8 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', vollbildGewechselt)
|
||||
window.removeEventListener('keydown', tasteAbfangen)
|
||||
window.clearInterval(wache)
|
||||
window.clearTimeout(neustart)
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
})
|
||||
|
||||
144
frontend/src/components/VerwaltungTab.vue
Normal file
144
frontend/src/components/VerwaltungTab.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const neuerName = ref('')
|
||||
const fehler = ref('')
|
||||
|
||||
async function anlegen() {
|
||||
fehler.value = ''
|
||||
const resp = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: neuerName.value }),
|
||||
})
|
||||
if (resp.ok) neuerName.value = ''
|
||||
else fehler.value = 'Der Name ist leer oder schon vergeben.'
|
||||
}
|
||||
|
||||
async function entfernen(user) {
|
||||
await fetch(`/api/users/${user.id}`, { method: 'DELETE' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="verwaltung-tab">
|
||||
<section>
|
||||
<h3>Benutzer</h3>
|
||||
|
||||
<div class="anlegen">
|
||||
<input
|
||||
v-model="neuerName"
|
||||
type="text"
|
||||
placeholder="Name hinzufügen"
|
||||
maxlength="40"
|
||||
@keyup.enter="anlegen"
|
||||
/>
|
||||
<button type="button" @click="anlegen">Hinzufügen</button>
|
||||
</div>
|
||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||
|
||||
<ul v-if="items.length" class="liste">
|
||||
<li v-for="u in items" :key="u.id">
|
||||
<span class="name">{{ u.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="weg"
|
||||
title="Aus der Runde nehmen"
|
||||
@click="entfernen(u)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="leer">Noch niemand in der Runde.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.verwaltung-tab {
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anlegen {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.anlegen input {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.anlegen button {
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.anlegen button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.liste li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.weg {
|
||||
padding: 0.15rem 0.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weg:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.leer,
|
||||
.fehler {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fehler {
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
const props = defineProps({ host: Boolean, name: { type: String, default: '' } })
|
||||
const props = defineProps({ host: Boolean })
|
||||
const emit = defineEmits(['open', 'changed'])
|
||||
|
||||
const items = ref([])
|
||||
@@ -47,7 +47,7 @@ async function bewerten(item, sterne) {
|
||||
await fetch(`/api/watched/${item.id}/rating`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stars: sterne, name: props.name || null }),
|
||||
body: JSON.stringify({ stars: sterne }),
|
||||
})
|
||||
} catch {
|
||||
fehler.value = 'Bewerten hat nicht geklappt.'
|
||||
|
||||
BIN
frontend/src/components/WuenscheTab.vue
Normal file
BIN
frontend/src/components/WuenscheTab.vue
Normal file
Binary file not shown.
Reference in New Issue
Block a user