update
This commit is contained in:
@@ -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
75
backend/app/punkte.py
Normal 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()}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
@@ -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."]
|
||||
|
||||
|
||||
|
||||
106
backend/tests/test_punkte.py
Normal file
106
backend/tests/test_punkte.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">●</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">●</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;
|
||||
|
||||
54
frontend/src/components/PersonName.vue
Normal file
54
frontend/src/components/PersonName.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user