This commit is contained in:
team3
2026-09-29 00:54:58 +02:00
parent f7c9017946
commit f80b8c5826
20 changed files with 997 additions and 77 deletions

View File

@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
from . import (
backup,
punkte,
db,
events,
features,
@@ -364,8 +365,9 @@ def mitglied(sid):
return ich
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
letzter_dreh = {"ergebnis": None, "start": 0.0}
# 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")
@@ -394,6 +396,7 @@ def drehen():
letzter_dreh["ergebnis"] = ergebnis
letzter_dreh["start"] = time.monotonic()
letzter_dreh["teilnehmer"] = suggestions.teilnehmer()
events.senden("spin", ergebnis)
return {"started": True, **ergebnis}
@@ -418,6 +421,7 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
sid = sitzung(response, filmabend_sid)
eintraege = suggestions.alle()
ich = users.besitzer(sid)
dabei = suggestions.dabei_alle()
return {
"items": [
{k: v for k, v in e.items() if k not in ("session_id", "user_id")}
@@ -428,9 +432,22 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
(e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)),
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")
async def vorschlagen(
response: Response,
@@ -595,8 +612,10 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
staende = punkte.alle()
leer = punkte.stand(0)
return {
"items": users.alle(),
"items": [{**u, **staende.get(u["id"], leer)} for u in users.alle()],
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@@ -739,17 +758,45 @@ def name_waehlen(
@app.get("/api/watched")
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return watched.alle(sid)
return watched.alle(sid, users.ist_host(sid))
@app.post("/api/watched")
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))
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")
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}")
def gesehen_entfernen(watched_id: int):
entfernt = watched.entfernen(watched_id)

75
backend/app/punkte.py Normal file
View File

@@ -0,0 +1,75 @@
"""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
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 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()}

View File

@@ -32,6 +32,17 @@ 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"
# So lange läuft die Animation im Browser. Der Server merkt sich das
@@ -54,9 +65,10 @@ BRUCH_ANTEIL = 0.5
def init():
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():
@@ -196,4 +208,40 @@ def loeschen(session_id):
def alles_loeschen():
"""Host setzt die Runde zurück."""
with connect() as conn:
conn.execute("DELETE FROM dabei")
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)

View File

@@ -56,6 +56,28 @@ CREATE TABLE IF NOT EXISTS watched_notes (
);
"""
# 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
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_KOMMENTAR = 4000
@@ -66,11 +88,10 @@ AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
def init():
with connect() as conn:
conn.executescript(SCHEMA + RATINGS + NOTES)
# Eine Stimme je Person und Film
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")
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
conn.execute("DROP TABLE IF EXISTS watched_hearts")
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)")}
@@ -106,9 +127,12 @@ def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
def alle(session_id, host=False):
"""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:
filme = conn.execute(
@@ -125,10 +149,26 @@ def alle(session_id):
"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"],
@@ -136,6 +176,9 @@ def alle(session_id):
"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
@@ -176,7 +219,10 @@ def alle(session_id):
"mine": gehoert_mir(s, session_id, ich),
}
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,
"count": len(werte),
@@ -207,9 +253,31 @@ def markieren(movie):
return dict(row)
def entfernen(watched_id):
"""Samt Wertungen und Kommentaren."""
def dabei_setzen(watched_id, user_ids):
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
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,))
return conn.execute(
@@ -326,12 +394,35 @@ def anmerkung_loeschen(note_id, session_id, host):
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():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

Binary file not shown.

View File

@@ -9,8 +9,7 @@ def abend(client, film, movie_id=1):
def wertung(client, watched_id, stars=4):
client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars})
film = next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
return next(r for r in film["ratings"] if r["mine"])
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
def film_von(client, watched_id):
@@ -103,8 +102,9 @@ def test_wertungen_und_kommentare_sind_unabhaengig(client, film):
wertung(client, watched_id, 5)
kommentar(client, watched_id, "Diskussion.")
f = film_von(client, watched_id)
assert f["ratings"][0]["stars"] == 5
assert "text" not in f["ratings"][0]
assert 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."]

View File

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

View File

@@ -16,7 +16,8 @@ def test_wertung_ist_nur_sterne(client, film):
assert eintrag["mine"] == 4
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert "text" not in eintrag["ratings"][0]
# Wer wie gewertet hat, sieht nur der Host
assert eintrag["ratings"] == []
assert "mine_text" not in eintrag
@@ -58,7 +59,8 @@ def test_fremde_wertung_ist_nicht_meine(client, client2, film):
[eintrag] = client2.get("/api/watched").json()
assert eintrag["mine"] is None
assert eintrag["count"] == 1
assert eintrag["ratings"][0]["mine"] is False
assert eintrag["ratings"] == []
assert eintrag["count"] == 1
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
[eintrag] = client2.get("/api/watched").json()

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
@@ -41,6 +41,9 @@ const {
ladeWuensche,
} = useMeins({ host })
// Das Level zu einem Namen — für die Badges vor Namen überall auf der Seite
provide('levelVon', (name) => runde.value.find((u) => u.name === name)?.level ?? null)
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die
// Reiterliste fragt danach.
@@ -374,6 +377,7 @@ onMounted(load)
ref="rad"
:host="host"
:angemeldet="angemeldet"
:ich="ich"
:merkliste="merkliste"
:mine="meinVorschlag?.id ?? null"
:gesehen-ids="gesehenIds"
@@ -385,6 +389,8 @@ onMounted(load)
v-else-if="activeTab === 'watched'"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
:ich="ich"
@open="selected = $event"
@changed="ladeGesehen"
/>

View File

@@ -9,10 +9,13 @@ 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'])
@@ -116,6 +119,16 @@ async function sichtbarkeit() {
})
}
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' })
}
@@ -139,37 +152,42 @@ async function entfernen() {
<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">von 5 · {{ item.count }} {{ item.count === 1 ? 'Stimme' : 'Stimmen' }}</span>
<span class="von" :title="`${item.count} ${item.count === 1 ? 'Stimme' : 'Stimmen'}`">
({{ item.count }})
</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
<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>
<div v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
<span class="deine">Du:</span>
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebt = s"
@click="bewerten(s)"
>
★
</button>
</div>
<ul v-if="wertungen.length" class="stimmen">
<!-- 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 }">{{ r.name || 'Anonym' }}</span>
<span class="wer" :class="{ eigen: r.mine }"><PersonName :name="r.name" /></span>
<button
v-if="host"
type="button"
@@ -182,6 +200,26 @@ async function entfernen() {
</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
@@ -327,8 +365,8 @@ h2:hover {
.urteil {
display: flex;
gap: 0.5rem;
align-items: baseline;
gap: 0.4rem;
align-items: center;
margin-top: 0.3rem;
}
@@ -345,26 +383,16 @@ h2:hover {
}
.wertung {
display: flex;
gap: 0.1rem;
display: inline-flex;
gap: 0.05rem;
align-items: center;
}
.wertung .link {
margin-left: 0.8rem;
}
.deine {
margin-right: 0.4rem;
color: var(--muted);
font-size: 0.85rem;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
margin-left: 0.6rem;
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.6rem;
font-size: 1.35rem;
line-height: 1;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
@@ -425,6 +453,34 @@ h2: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;

View File

@@ -1,11 +1,12 @@
<script setup>
// Ein Kommentar in der Diskussion: der Text mit Name, Datum und
// „Kommentieren“. Antworten rücken darunter ein — die Komponente ruft sich dafür selbst auf, so tief
// 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 { ref } from 'vue'
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 },
@@ -32,6 +33,22 @@ async function antworten() {
}
}
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' })
}
@@ -50,7 +67,17 @@ function datum(iso) {
<div class="inhalt">
<div class="markdown" v-html="rendern(kommentar.text)"></div>
<p class="meta">
<span :class="{ eigen: kommentar.mine }">{{ kommentar.name || 'Anonym' }}</span>
<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>
@@ -113,6 +140,37 @@ function datum(iso) {
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;
@@ -121,7 +179,11 @@ function datum(iso) {
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;
@@ -204,6 +266,11 @@ function datum(iso) {
}
@media (max-width: 640px), (pointer: coarse) {
.pille {
padding: 0.25rem 0.55rem;
font-size: 0.82rem;
}
.link,
.weg {
padding: 0.3rem 0.4rem;

View File

@@ -1,6 +1,7 @@
<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({
items: { type: Array, default: () => [] },
@@ -19,6 +20,44 @@ const halter = ref(null)
// 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
if (!benutzer) return props.anmelden(null)
@@ -57,10 +96,39 @@ onUnmounted(() => {
<template>
<div ref="halter" class="wahl">
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
{{ mine ? mine.name : 'Anonym' }}
<button
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 }">
{{ meins.level }}
<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>
<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">
<li>
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
@@ -69,8 +137,10 @@ onUnmounted(() => {
</li>
<li v-for="u in items" :key="u.id">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
{{ u.name }}
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">&#9679;</span>
<span class="zeile" :title="`Erfahrung: ${u.punkte - u.von} / ${u.bis - u.von}`">
<PersonName :name="u.name" :level="u.level" />
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">&#9679;</span>
</span>
</button>
</li>
</ul>
@@ -85,6 +155,205 @@ onUnmounted(() => {
</template>
<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: 2.1rem;
height: 2.1rem;
padding-top: 0.1em;
color: #f3d9db;
font-size: 0.95rem;
font-weight: 700;
/* Wie die kleinen Badges: Weinrot mit hellem Rand. Der Ring in
Hintergrundfarbe hält die Leiste einen Pixel vom Kreis weg. */
background: #6e1418;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
box-shadow: 0 0 0 1px var(--bg);
}
/* 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 {
position: relative;
align-self: center;
@@ -92,9 +361,10 @@ onUnmounted(() => {
}
.knopf {
padding: 0.32rem 0.7rem;
padding: 0.3rem 0.8rem 0.3rem 0.4rem;
color: var(--text);
font-size: 0.82rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;

View File

@@ -0,0 +1,54 @@
<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">
<span v-if="stufe" class="lvl">{{ stufe }}</span>{{ 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;
}
/* Ein Kreis, auch bei zweistelligen Zahlen — dann etwas enger gesetzt */
.lvl {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65em;
height: 1.65em;
/* Ziffern haben keine Unterlänge — rechnerisch mittig sitzen sie
optisch zu hoch. Ein Hauch Luft oben gleicht das aus. */
padding-top: 0.12em;
color: #f3d9db;
font-size: 0.68em;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
/* Weinrot statt Signalrot: in Listen mit vielen Namen bleibt es ruhig */
background: #6e1418;
/* Der helle Rand hebt den Kreis vom dunklen Grund ab (3:1), die
Fläche darf ruhig bleiben */
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
}
</style>

View File

@@ -3,6 +3,7 @@
// 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
@@ -104,7 +105,7 @@ function zuruecksetzen() {
:class="{ on: gewaehlt.includes(person.name) }"
@click="umschalten(person.name)"
>
{{ person.name }}
<PersonName :name="person.name" />
<span class="count">{{ person.anzahl }}</span>
</button>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
import { posterUrl } from '../images'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -87,7 +88,7 @@ async function entfernen(user) {
<ul v-if="liste.length" class="liste">
<li v-for="u in liste" :key="u.id">
<div class="zeile">
<span class="name">{{ u.name }}</span>
<span class="name"><PersonName :name="u.name" :level="u.level" /></span>
<button
v-if="u.geschuetzt"
type="button"

View File

@@ -2,6 +2,7 @@
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: () => [] },
@@ -12,6 +13,9 @@ const props = defineProps({
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'])
@@ -31,6 +35,11 @@ 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
@@ -72,6 +81,16 @@ async function wuerfeln() {
</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>
@@ -98,15 +117,63 @@ async function wuerfeln() {
<span v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</span>
<span class="von">{{ item.name || 'Anonym' }}</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;

View File

@@ -12,6 +12,10 @@ 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'])
@@ -230,6 +234,11 @@ async function laden_() {
}
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(() => {
laden_()
window.addEventListener('keydown', tastatur)
@@ -270,6 +279,7 @@ onUnmounted(() => {
:item="item"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
@open="emit('open', $event)"
/>
</div>

View File

@@ -1,14 +1,17 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
import PersonName from './PersonName.vue'
defineProps({
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 },
@@ -17,6 +20,9 @@ defineProps({
const emit = defineEmits(['open', 'reset'])
const items = ref([])
// Wer ohne Vorschlag dabei ist — zählt beim Dreh als anwesend
const dabei = ref([])
const dabeiMine = ref(false)
const laden = ref(true)
const fehler = ref('')
@@ -48,6 +54,8 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
dabei.value = daten.dabei || []
dabeiMine.value = !!daten.dabei_mine
ruhen()
} catch (err) {
fehler.value = err.message
@@ -83,6 +91,11 @@ async function oeffnen(item) {
if (resp.ok) emit('open', await resp.json())
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
@@ -162,7 +175,7 @@ defineExpose({ neu: laden_ })
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
{{ gewinner.year }}<span v-if="gewinner.name"> · von <PersonName :name="gewinner.name" /></span>
</p>
</div>
</div>
@@ -176,6 +189,8 @@ defineExpose({ neu: laden_ })
:merkliste="merkliste"
:mine="mine"
:gesehen-ids="gesehenIds"
:dabei="dabei"
:dabei-mine="dabeiMine"
@open="oeffnen"
@fehler="fehler = $event"
/>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -180,7 +181,7 @@ function datum(iso) {
<template v-else>
<p class="text">{{ w.text }}</p>
<p class="meta">
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span>
<span :class="{ eigen: w.mine }"><PersonName :name="w.name" /></span>
<span class="punkt">·</span>{{ datum(w.created_at) }}
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt
@@ -200,7 +201,7 @@ function datum(iso) {
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
<span class="wer" :class="{ eigen: n.mine }"><PersonName :name="n.name" /></span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"

View File

@@ -104,6 +104,9 @@ export function useMeins({ host }) {
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()
})
})