update
This commit is contained in:
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
backup,
|
backup,
|
||||||
|
punkte,
|
||||||
db,
|
db,
|
||||||
events,
|
events,
|
||||||
features,
|
features,
|
||||||
@@ -364,8 +365,9 @@ def mitglied(sid):
|
|||||||
return ich
|
return ich
|
||||||
|
|
||||||
|
|
||||||
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
|
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen
|
||||||
letzter_dreh = {"ergebnis": None, "start": 0.0}
|
# kann — samt Anwesenheit der Runde, die beim Eintragen am Abend landet
|
||||||
|
letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
@@ -394,6 +396,7 @@ def drehen():
|
|||||||
|
|
||||||
letzter_dreh["ergebnis"] = ergebnis
|
letzter_dreh["ergebnis"] = ergebnis
|
||||||
letzter_dreh["start"] = time.monotonic()
|
letzter_dreh["start"] = time.monotonic()
|
||||||
|
letzter_dreh["teilnehmer"] = suggestions.teilnehmer()
|
||||||
events.senden("spin", ergebnis)
|
events.senden("spin", ergebnis)
|
||||||
return {"started": True, **ergebnis}
|
return {"started": True, **ergebnis}
|
||||||
|
|
||||||
@@ -418,6 +421,7 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
|
|||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
eintraege = suggestions.alle()
|
eintraege = suggestions.alle()
|
||||||
ich = users.besitzer(sid)
|
ich = users.besitzer(sid)
|
||||||
|
dabei = suggestions.dabei_alle()
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{k: v for k, v in e.items() if k not in ("session_id", "user_id")}
|
{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)),
|
(e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
# Wer ohne Vorschlag dabei ist
|
||||||
|
"dabei": [d["name"] or "Anonym" for d in dabei],
|
||||||
|
"dabei_mine": any(users.gehoert_mir(d, sid, ich) for d in dabei),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dabei")
|
||||||
|
def dabei(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Dabei ohne Vorschlag — an oder wieder aus."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
steht = suggestions.dabei_umschalten(sid)
|
||||||
|
events.senden("suggestions")
|
||||||
|
return {"dabei": steht}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/suggestions")
|
@app.post("/api/suggestions")
|
||||||
async def vorschlagen(
|
async def vorschlagen(
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -595,8 +612,10 @@ def vergessen(
|
|||||||
@app.get("/api/users")
|
@app.get("/api/users")
|
||||||
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
staende = punkte.alle()
|
||||||
|
leer = punkte.stand(0)
|
||||||
return {
|
return {
|
||||||
"items": users.alle(),
|
"items": [{**u, **staende.get(u["id"], leer)} for u in users.alle()],
|
||||||
"mine": users.aktuell(sid),
|
"mine": users.aktuell(sid),
|
||||||
"host": users.ist_host(sid),
|
"host": users.ist_host(sid),
|
||||||
}
|
}
|
||||||
@@ -739,17 +758,45 @@ def name_waehlen(
|
|||||||
@app.get("/api/watched")
|
@app.get("/api/watched")
|
||||||
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
return watched.alle(sid)
|
return watched.alle(sid, users.ist_host(sid))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/watched")
|
@app.post("/api/watched")
|
||||||
async def als_gesehen(movie_id: int = Body(..., embed=True)):
|
async def als_gesehen(movie_id: int = Body(..., embed=True)):
|
||||||
"""Der Host trägt ein, was am Abend lief."""
|
"""Der Host trägt ein, was am Abend lief. Ist es der gezogene Film,
|
||||||
|
hängt die Runde vom Rad als Anwesenheit am Abend."""
|
||||||
eintrag = watched.markieren(await film_holen(movie_id))
|
eintrag = watched.markieren(await film_holen(movie_id))
|
||||||
|
gezogen = letzter_dreh["ergebnis"]
|
||||||
|
if gezogen and gezogen["gewinner"]["movie_id"] == movie_id and letzter_dreh["teilnehmer"]:
|
||||||
|
watched.dabei_setzen(eintrag["id"], letzter_dreh["teilnehmer"])
|
||||||
events.senden("watched")
|
events.senden("watched")
|
||||||
return eintrag
|
return eintrag
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/watched/{watched_id}/dabei")
|
||||||
|
def dabei_aendern(watched_id: int, user_ids: list[int] = Body(..., embed=True)):
|
||||||
|
"""Der Host berichtigt, wer dabei war — etwa wer spät kam."""
|
||||||
|
watched.dabei_setzen(watched_id, user_ids)
|
||||||
|
events.senden("watched")
|
||||||
|
return {"dabei": user_ids}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/watched/hearts")
|
||||||
|
def herz_setzen(
|
||||||
|
response: Response,
|
||||||
|
note_id: int = Body(..., embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Ein Klick setzt das Herz, der nächste nimmt es zurück."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
mitglied(sid)
|
||||||
|
steht = watched.herz(sid, note_id)
|
||||||
|
if steht is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kommentar nicht gefunden")
|
||||||
|
events.senden("watched")
|
||||||
|
return {"hearted": steht}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/watched/{watched_id}")
|
@app.delete("/api/watched/{watched_id}")
|
||||||
def gesehen_entfernen(watched_id: int):
|
def gesehen_entfernen(watched_id: int):
|
||||||
entfernt = watched.entfernen(watched_id)
|
entfernt = watched.entfernen(watched_id)
|
||||||
|
|||||||
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"
|
COOKIE = "filmabend_sid"
|
||||||
|
|
||||||
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
# So lange läuft die Animation im Browser. Der Server merkt sich das
|
||||||
@@ -54,9 +65,10 @@ BRUCH_ANTEIL = 0.5
|
|||||||
|
|
||||||
def init():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA + DABEI)
|
||||||
# Ein Vorschlag je Person
|
# Ein Vorschlag je Person
|
||||||
users.je_person(conn, "suggestions", SCHEMA)
|
users.je_person(conn, "suggestions", SCHEMA)
|
||||||
|
users.je_person(conn, "dabei", DABEI)
|
||||||
|
|
||||||
|
|
||||||
def neue_session():
|
def neue_session():
|
||||||
@@ -196,4 +208,40 @@ def loeschen(session_id):
|
|||||||
def alles_loeschen():
|
def alles_loeschen():
|
||||||
"""Host setzt die Runde zurück."""
|
"""Host setzt die Runde zurück."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM dabei")
|
||||||
return conn.execute("DELETE FROM suggestions").rowcount
|
return conn.execute("DELETE FROM suggestions").rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def dabei_umschalten(session_id):
|
||||||
|
"""Dabei ohne Vorschlag — der zweite Klick nimmt es zurück.
|
||||||
|
Gibt zurück, ob es jetzt gesetzt ist."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
weg = conn.execute(
|
||||||
|
f"DELETE FROM dabei WHERE {WEM}", (ich, ich, session_id)
|
||||||
|
).rowcount
|
||||||
|
if weg:
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dabei (session_id, user_id, created_at) VALUES (?, ?, ?)",
|
||||||
|
(session_id, ich, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def dabei_alle():
|
||||||
|
"""Wer sich ohne Vorschlag eingetragen hat — Namen und Kennungen."""
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT d.*, u.name FROM dabei d LEFT JOIN users u ON u.id = d.user_id "
|
||||||
|
"ORDER BY d.created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def teilnehmer():
|
||||||
|
"""user_ids aller Anwesenden: Vorschlagende und Eingetragene."""
|
||||||
|
with connect() as conn:
|
||||||
|
ids = {r["user_id"] for r in conn.execute("SELECT user_id FROM suggestions")}
|
||||||
|
ids |= {r["user_id"] for r in conn.execute("SELECT user_id FROM dabei")}
|
||||||
|
return sorted(i for i in ids if i is not None)
|
||||||
|
|||||||
@@ -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
|
MAX_STERNE = 5
|
||||||
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
|
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
|
||||||
MAX_KOMMENTAR = 4000
|
MAX_KOMMENTAR = 4000
|
||||||
@@ -66,11 +88,10 @@ AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
|
|||||||
|
|
||||||
def init():
|
def init():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.executescript(SCHEMA + RATINGS + NOTES)
|
conn.executescript(SCHEMA + RATINGS + NOTES + HEARTS + DABEI)
|
||||||
# Eine Stimme je Person und Film
|
# Eine Stimme je Person und Film, ein Herz je Person und Kommentar
|
||||||
users.je_person(conn, "ratings", RATINGS, "watched_id")
|
users.je_person(conn, "ratings", RATINGS, "watched_id")
|
||||||
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
|
users.je_person(conn, "watched_hearts", HEARTS, "note_id")
|
||||||
conn.execute("DROP TABLE IF EXISTS watched_hearts")
|
|
||||||
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
|
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
|
||||||
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
|
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
|
||||||
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched_notes)")}
|
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")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def alle(session_id):
|
def alle(session_id, host=False):
|
||||||
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
|
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme, Kommentaren
|
||||||
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
|
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)
|
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
filme = conn.execute(
|
filme = conn.execute(
|
||||||
@@ -125,10 +149,26 @@ def alle(session_id):
|
|||||||
"LEFT JOIN users u ON u.id = n.user_id "
|
"LEFT JOIN users u ON u.id = n.user_id "
|
||||||
"ORDER BY n.created_at ASC"
|
"ORDER BY n.created_at ASC"
|
||||||
).fetchall()
|
).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)
|
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):
|
def notiz(n):
|
||||||
|
meine_herzen = herz_je_notiz.get(n["id"], [])
|
||||||
return {
|
return {
|
||||||
"id": n["id"],
|
"id": n["id"],
|
||||||
"text": n["text"],
|
"text": n["text"],
|
||||||
@@ -136,6 +176,9 @@ def alle(session_id):
|
|||||||
"created_at": n["created_at"],
|
"created_at": n["created_at"],
|
||||||
"mine": gehoert_mir(n, session_id, ich),
|
"mine": gehoert_mir(n, session_id, ich),
|
||||||
"replies": [],
|
"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
|
# 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),
|
"mine": gehoert_mir(s, session_id, ich),
|
||||||
}
|
}
|
||||||
for s in eigene
|
for s in eigene
|
||||||
],
|
]
|
||||||
|
if host
|
||||||
|
else [],
|
||||||
|
"dabei": dabei_je_film.get(film["id"], []),
|
||||||
"notes": kommentare.get(film["id"], []),
|
"notes": kommentare.get(film["id"], []),
|
||||||
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
||||||
"count": len(werte),
|
"count": len(werte),
|
||||||
@@ -207,9 +253,31 @@ def markieren(movie):
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def entfernen(watched_id):
|
def dabei_setzen(watched_id, user_ids):
|
||||||
"""Samt Wertungen und Kommentaren."""
|
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO watched_dabei (watched_id, user_id) VALUES (?, ?)",
|
||||||
|
[(watched_id, i) for i in user_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _herzen_weg(conn, note_ids):
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM watched_hearts WHERE note_id = ?", [(i,) for i in note_ids]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def entfernen(watched_id):
|
||||||
|
"""Samt Wertungen, Kommentaren, Herzen und Anwesenheit."""
|
||||||
|
with connect() as conn:
|
||||||
|
notizen = [
|
||||||
|
r["id"]
|
||||||
|
for r in conn.execute("SELECT id FROM watched_notes WHERE watched_id = ?", (watched_id,))
|
||||||
|
]
|
||||||
|
_herzen_weg(conn, notizen)
|
||||||
|
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
|
||||||
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
|
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
|
||||||
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
|
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
@@ -326,12 +394,35 @@ def anmerkung_loeschen(note_id, session_id, host):
|
|||||||
if not host and not gehoert_mir(zeile, session_id, ich):
|
if not host and not gehoert_mir(zeile, session_id, ich):
|
||||||
return 0
|
return 0
|
||||||
alle_ids = [note_id, *_nachkommen(conn, note_id)]
|
alle_ids = [note_id, *_nachkommen(conn, note_id)]
|
||||||
|
_herzen_weg(conn, alle_ids)
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
|
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
|
||||||
)
|
)
|
||||||
return len(alle_ids)
|
return len(alle_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def herz(session_id, note_id):
|
||||||
|
"""Herz setzen oder zurücknehmen. None, wenn es den Kommentar nicht
|
||||||
|
gibt, sonst ob das Herz jetzt steht."""
|
||||||
|
ich = besitzer(session_id)
|
||||||
|
with connect() as conn:
|
||||||
|
weg = conn.execute(
|
||||||
|
f"DELETE FROM watched_hearts WHERE note_id = ? AND {WEM}",
|
||||||
|
(note_id, ich, ich, session_id),
|
||||||
|
).rowcount
|
||||||
|
if weg:
|
||||||
|
return False
|
||||||
|
da = conn.execute("SELECT 1 FROM watched_notes WHERE id = ?", (note_id,)).fetchone()
|
||||||
|
if not da:
|
||||||
|
return None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO watched_hearts (note_id, session_id, user_id, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(note_id, session_id, ich, _jetzt()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ids():
|
def ids():
|
||||||
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
|||||||
Binary file not shown.
@@ -9,8 +9,7 @@ def abend(client, film, movie_id=1):
|
|||||||
|
|
||||||
def wertung(client, watched_id, stars=4):
|
def wertung(client, watched_id, stars=4):
|
||||||
client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars})
|
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(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
|
||||||
return next(r for r in film["ratings"] if r["mine"])
|
|
||||||
|
|
||||||
|
|
||||||
def film_von(client, watched_id):
|
def film_von(client, watched_id):
|
||||||
@@ -103,8 +102,9 @@ def test_wertungen_und_kommentare_sind_unabhaengig(client, film):
|
|||||||
wertung(client, watched_id, 5)
|
wertung(client, watched_id, 5)
|
||||||
kommentar(client, watched_id, "Diskussion.")
|
kommentar(client, watched_id, "Diskussion.")
|
||||||
f = film_von(client, watched_id)
|
f = film_von(client, watched_id)
|
||||||
assert f["ratings"][0]["stars"] == 5
|
assert f["average"] == 5.0 and f["mine"] == 5
|
||||||
assert "text" not in f["ratings"][0]
|
# Die Namen an den Wertungen sieht nur der Host
|
||||||
|
assert f["ratings"] == []
|
||||||
assert [n["text"] for n in f["notes"]] == ["Diskussion."]
|
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["mine"] == 4
|
||||||
assert eintrag["average"] == 4.0
|
assert eintrag["average"] == 4.0
|
||||||
assert eintrag["count"] == 1
|
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
|
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()
|
[eintrag] = client2.get("/api/watched").json()
|
||||||
assert eintrag["mine"] is None
|
assert eintrag["mine"] is None
|
||||||
assert eintrag["count"] == 1
|
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})
|
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
|
||||||
[eintrag] = client2.get("/api/watched").json()
|
[eintrag] = client2.get("/api/watched").json()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<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 MovieCard from './components/MovieCard.vue'
|
||||||
import FilterSidebar from './components/FilterSidebar.vue'
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
@@ -41,6 +41,9 @@ const {
|
|||||||
ladeWuensche,
|
ladeWuensche,
|
||||||
} = useMeins({ host })
|
} = 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 —
|
// 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
|
// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die
|
||||||
// Reiterliste fragt danach.
|
// Reiterliste fragt danach.
|
||||||
@@ -374,6 +377,7 @@ onMounted(load)
|
|||||||
ref="rad"
|
ref="rad"
|
||||||
:host="host"
|
:host="host"
|
||||||
:angemeldet="angemeldet"
|
:angemeldet="angemeldet"
|
||||||
|
:ich="ich"
|
||||||
:merkliste="merkliste"
|
:merkliste="merkliste"
|
||||||
:mine="meinVorschlag?.id ?? null"
|
:mine="meinVorschlag?.id ?? null"
|
||||||
:gesehen-ids="gesehenIds"
|
:gesehen-ids="gesehenIds"
|
||||||
@@ -385,6 +389,8 @@ onMounted(load)
|
|||||||
v-else-if="activeTab === 'watched'"
|
v-else-if="activeTab === 'watched'"
|
||||||
:host="host"
|
:host="host"
|
||||||
:angemeldet="angemeldet"
|
:angemeldet="angemeldet"
|
||||||
|
:runde="runde"
|
||||||
|
:ich="ich"
|
||||||
@open="selected = $event"
|
@open="selected = $event"
|
||||||
@changed="ladeGesehen"
|
@changed="ladeGesehen"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import { posterUrl } from '../images'
|
|||||||
import { rendern } from '../markdown'
|
import { rendern } from '../markdown'
|
||||||
import Kommentar from './Kommentar.vue'
|
import Kommentar from './Kommentar.vue'
|
||||||
import MarkdownFeld from './MarkdownFeld.vue'
|
import MarkdownFeld from './MarkdownFeld.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: { type: Object, required: true },
|
item: { type: Object, required: true },
|
||||||
host: Boolean,
|
host: Boolean,
|
||||||
|
// Die Runde — der Host hakt darin ab, wer dabei war
|
||||||
|
runde: { type: Array, default: () => [] },
|
||||||
angemeldet: Boolean,
|
angemeldet: Boolean,
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['open'])
|
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() {
|
async function entfernen() {
|
||||||
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
|
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -139,19 +152,17 @@ async function entfernen() {
|
|||||||
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
|
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
|
||||||
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
|
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Eine Zeile: Schnitt, Stimmenzahl in Klammern, dann die
|
||||||
|
eigenen Sterne zum Klicken -->
|
||||||
<p class="urteil">
|
<p class="urteil">
|
||||||
<template v-if="item.count">
|
<template v-if="item.count">
|
||||||
<span class="wert">{{ item.average.toFixed(1) }}</span>
|
<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>
|
</template>
|
||||||
<span v-else class="von">Noch keine Wertung</span>
|
<span v-else-if="!angemeldet" class="von">Noch keine Wertung</span>
|
||||||
</p>
|
<span v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
|
||||||
|
|
||||||
<div v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
|
|
||||||
<span class="deine">Du:</span>
|
|
||||||
<button
|
<button
|
||||||
v-for="s in STERNE"
|
v-for="s in STERNE"
|
||||||
:key="s"
|
:key="s"
|
||||||
@@ -159,17 +170,24 @@ async function entfernen() {
|
|||||||
class="stern"
|
class="stern"
|
||||||
:class="{ an: gefuellt(s) }"
|
:class="{ an: gefuellt(s) }"
|
||||||
:aria-label="`${s} von 5`"
|
:aria-label="`${s} von 5`"
|
||||||
|
:title="item.mine ? 'Deine Wertung' : 'Bewerten'"
|
||||||
@mouseenter="schwebt = s"
|
@mouseenter="schwebt = s"
|
||||||
@click="bewerten(s)"
|
@click="bewerten(s)"
|
||||||
>
|
>
|
||||||
★
|
★
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<ul v-if="wertungen.length" class="stimmen">
|
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||||
|
|
||||||
|
<!-- Nur in der Hostansicht: in der Gastansicht bleibt die Wertung
|
||||||
|
anonym, auch wenn die Sitzung freigeschaltet ist -->
|
||||||
|
<ul v-if="host && wertungen.length" class="stimmen">
|
||||||
<li v-for="r in wertungen" :key="r.id">
|
<li v-for="r in wertungen" :key="r.id">
|
||||||
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
|
<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
|
<button
|
||||||
v-if="host"
|
v-if="host"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -182,6 +200,26 @@ async function entfernen() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<div v-if="host" class="host-zeile">
|
||||||
<label>
|
<label>
|
||||||
Geschaut am
|
Geschaut am
|
||||||
@@ -327,8 +365,8 @@ h2:hover {
|
|||||||
|
|
||||||
.urteil {
|
.urteil {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,26 +383,16 @@ h2:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wertung {
|
.wertung {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 0.1rem;
|
gap: 0.05rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
margin-left: 0.6rem;
|
||||||
|
|
||||||
.wertung .link {
|
|
||||||
margin-left: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deine {
|
|
||||||
margin-right: 0.4rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stern {
|
.stern {
|
||||||
padding: 0 0.05rem;
|
padding: 0 0.05rem;
|
||||||
color: var(--line);
|
color: var(--line);
|
||||||
font-size: 1.6rem;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
@@ -425,6 +453,34 @@ h2:hover {
|
|||||||
color: var(--accent);
|
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 {
|
.host-zeile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Ein Kommentar in der Diskussion: der Text mit Name, Datum und
|
// Ein Kommentar in der Diskussion: der Text, darunter Herz, Name, Datum
|
||||||
// „Kommentieren“. Antworten rücken darunter ein — die Komponente ruft sich dafür selbst auf, so tief
|
// 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
|
// es geht. Ab der vierten Ebene nicht weiter, sonst bleibt auf dem Handy
|
||||||
// kein Platz für den Text.
|
// kein Platz für den Text.
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { rendern } from '../markdown'
|
import { rendern } from '../markdown'
|
||||||
import MarkdownFeld from './MarkdownFeld.vue'
|
import MarkdownFeld from './MarkdownFeld.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
kommentar: { type: Object, required: true },
|
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() {
|
async function loeschen() {
|
||||||
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
|
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -50,7 +67,17 @@ function datum(iso) {
|
|||||||
<div class="inhalt">
|
<div class="inhalt">
|
||||||
<div class="markdown" v-html="rendern(kommentar.text)"></div>
|
<div class="markdown" v-html="rendern(kommentar.text)"></div>
|
||||||
<p class="meta">
|
<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) }}
|
<span class="punkt">·</span>{{ datum(kommentar.created_at) }}
|
||||||
<template v-if="angemeldet">
|
<template v-if="angemeldet">
|
||||||
<span class="punkt">·</span>
|
<span class="punkt">·</span>
|
||||||
@@ -113,6 +140,37 @@ function datum(iso) {
|
|||||||
background: rgba(255, 255, 255, 0.02);
|
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 {
|
.inhalt {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -121,7 +179,11 @@ function datum(iso) {
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Herz, Name, Datum und Antworten mittig auf einer Linie */
|
||||||
.meta {
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -204,6 +266,11 @@ function datum(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px), (pointer: coarse) {
|
@media (max-width: 640px), (pointer: coarse) {
|
||||||
|
.pille {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.link,
|
.link,
|
||||||
.weg {
|
.weg {
|
||||||
padding: 0.3rem 0.4rem;
|
padding: 0.3rem 0.4rem;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import FilmLogin from './FilmLogin.vue'
|
import FilmLogin from './FilmLogin.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
@@ -19,6 +20,44 @@ const halter = ref(null)
|
|||||||
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
|
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
|
||||||
const anmeldung = ref(null)
|
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) {
|
function waehlen(benutzer) {
|
||||||
offen.value = false
|
offen.value = false
|
||||||
if (!benutzer) return props.anmelden(null)
|
if (!benutzer) return props.anmelden(null)
|
||||||
@@ -57,10 +96,39 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="halter" class="wahl">
|
<div ref="halter" class="wahl">
|
||||||
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
|
<button
|
||||||
{{ mine ? mine.name : 'Anonym' }}
|
type="button"
|
||||||
|
class="knopf"
|
||||||
|
:class="{ offen, schlicht: !meins }"
|
||||||
|
:title="meins ? `Erfahrung: ${meins.punkte - meins.von} / ${meins.bis - meins.von}` : ''"
|
||||||
|
@click="offen = !offen"
|
||||||
|
>
|
||||||
|
<!-- Wie ein Spiel-HUD: links der Levelkreis, rechts oben der Name,
|
||||||
|
darunter die Erfahrungsleiste, die am Kreis hängt -->
|
||||||
|
<template v-if="meins">
|
||||||
|
<span class="kreis" :class="{ puls: feuerwerk }">
|
||||||
|
{{ 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>
|
</button>
|
||||||
|
|
||||||
|
<span v-if="zuwachs" :key="zuwachs.key" class="zuwachs" :class="{ minus: zuwachs.wert < 0 }">
|
||||||
|
{{ zuwachs.wert > 0 ? '+' : '−' }}{{ Math.abs(zuwachs.wert) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<ul v-if="offen" class="menue">
|
<ul v-if="offen" class="menue">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
|
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
|
||||||
@@ -69,8 +137,10 @@ onUnmounted(() => {
|
|||||||
</li>
|
</li>
|
||||||
<li v-for="u in items" :key="u.id">
|
<li v-for="u in items" :key="u.id">
|
||||||
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
|
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
|
||||||
{{ u.name }}
|
<span class="zeile" :title="`Erfahrung: ${u.punkte - u.von} / ${u.bis - u.von}`">
|
||||||
|
<PersonName :name="u.name" :level="u.level" />
|
||||||
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">●</span>
|
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">●</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -85,6 +155,205 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Das Level als kleine Zahl im Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.3rem;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile .badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Fortschritt zum nächsten Level, als Strich am unteren Rand */
|
||||||
|
/* Kein Kasten: Kreis und Leiste stehen frei in der Kopfzeile */
|
||||||
|
.knopf:not(.schlicht) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Levelkreis, groß und vorn — liegt über dem Anfang der Leiste */
|
||||||
|
.kreis {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 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 {
|
.wahl {
|
||||||
position: relative;
|
position: relative;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -92,9 +361,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knopf {
|
.knopf {
|
||||||
padding: 0.32rem 0.7rem;
|
padding: 0.3rem 0.8rem 0.3rem 0.4rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
text-align: left;
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
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,
|
// Filterleiste im Katalog, nur mit dem, was hier zählt: wessen Sammlung,
|
||||||
// Horror oder nicht, und wie groß die Karten sind.
|
// Horror oder nicht, und wie groß die Karten sind.
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
|
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
|
||||||
@@ -104,7 +105,7 @@ function zuruecksetzen() {
|
|||||||
:class="{ on: gewaehlt.includes(person.name) }"
|
:class="{ on: gewaehlt.includes(person.name) }"
|
||||||
@click="umschalten(person.name)"
|
@click="umschalten(person.name)"
|
||||||
>
|
>
|
||||||
{{ person.name }}
|
<PersonName :name="person.name" />
|
||||||
<span class="count">{{ person.anzahl }}</span>
|
<span class="count">{{ person.anzahl }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import FilmSuche from './FilmSuche.vue'
|
import FilmSuche from './FilmSuche.vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
@@ -87,7 +88,7 @@ async function entfernen(user) {
|
|||||||
<ul v-if="liste.length" class="liste">
|
<ul v-if="liste.length" class="liste">
|
||||||
<li v-for="u in liste" :key="u.id">
|
<li v-for="u in liste" :key="u.id">
|
||||||
<div class="zeile">
|
<div class="zeile">
|
||||||
<span class="name">{{ u.name }}</span>
|
<span class="name"><PersonName :name="u.name" :level="u.level" /></span>
|
||||||
<button
|
<button
|
||||||
v-if="u.geschuetzt"
|
v-if="u.geschuetzt"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import { FARBEN } from '../composables/useZiehung'
|
import { FARBEN } from '../composables/useZiehung'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
@@ -12,6 +13,9 @@ const props = defineProps({
|
|||||||
merkliste: { type: Array, default: () => [] },
|
merkliste: { type: Array, default: () => [] },
|
||||||
mine: { type: Number, default: null },
|
mine: { type: Number, default: null },
|
||||||
gesehenIds: { type: Set, default: () => new Set() },
|
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'])
|
const emit = defineEmits(['open', 'fehler'])
|
||||||
|
|
||||||
@@ -31,6 +35,11 @@ const auswahl = computed(() => nachGenre(nurHorror.value))
|
|||||||
const anzahlHorror = computed(() => nachGenre(true).length)
|
const anzahlHorror = computed(() => nachGenre(true).length)
|
||||||
const anzahlOhne = computed(() => nachGenre(false).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() {
|
async function wuerfeln() {
|
||||||
const treffer = auswahl.value
|
const treffer = auswahl.value
|
||||||
if (!treffer.length || wuerfelt.value) return
|
if (!treffer.length || wuerfelt.value) return
|
||||||
@@ -72,6 +81,16 @@ async function wuerfeln() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -98,15 +117,63 @@ async function wuerfeln() {
|
|||||||
<span v-if="item.genres?.length" class="genres">
|
<span v-if="item.genres?.length" class="genres">
|
||||||
{{ item.genres.join(' · ') }}
|
{{ item.genres.join(' · ') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="von">{{ item.name || 'Anonym' }}</span>
|
<span class="von"><PersonName :name="item.name" /></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.sammlung-wuerfeln {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
|||||||
host: Boolean,
|
host: Boolean,
|
||||||
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
|
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
|
||||||
angemeldet: Boolean,
|
angemeldet: Boolean,
|
||||||
|
runde: { type: Array, default: () => [] },
|
||||||
|
// Wer gerade gewählt ist — wechselt der Name, gehören die "Du:"-Sterne
|
||||||
|
// einer anderen Person und müssen neu geladen werden
|
||||||
|
ich: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['open', 'changed'])
|
const emit = defineEmits(['open', 'changed'])
|
||||||
|
|
||||||
@@ -230,6 +234,11 @@ async function laden_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ich?.id,
|
||||||
|
() => laden_()
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
laden_()
|
laden_()
|
||||||
window.addEventListener('keydown', tastatur)
|
window.addEventListener('keydown', tastatur)
|
||||||
@@ -270,6 +279,7 @@ onUnmounted(() => {
|
|||||||
:item="item"
|
:item="item"
|
||||||
:host="host"
|
:host="host"
|
||||||
:angemeldet="angemeldet"
|
:angemeldet="angemeldet"
|
||||||
|
:runde="runde"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import { abonnieren } from '../live'
|
import { abonnieren } from '../live'
|
||||||
import { klang } from '../klang'
|
import { klang } from '../klang'
|
||||||
import { useZiehung } from '../composables/useZiehung'
|
import { useZiehung } from '../composables/useZiehung'
|
||||||
import VorschlagsListe from './VorschlagsListe.vue'
|
import VorschlagsListe from './VorschlagsListe.vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
host: Boolean,
|
host: Boolean,
|
||||||
angemeldet: 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
|
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
||||||
merkliste: { type: Array, default: () => [] },
|
merkliste: { type: Array, default: () => [] },
|
||||||
mine: { type: Number, default: null },
|
mine: { type: Number, default: null },
|
||||||
@@ -17,6 +20,9 @@ defineProps({
|
|||||||
const emit = defineEmits(['open', 'reset'])
|
const emit = defineEmits(['open', 'reset'])
|
||||||
|
|
||||||
const items = ref([])
|
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 laden = ref(true)
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
|
|
||||||
@@ -48,6 +54,8 @@ async function laden_() {
|
|||||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||||
const daten = await resp.json()
|
const daten = await resp.json()
|
||||||
items.value = daten.items
|
items.value = daten.items
|
||||||
|
dabei.value = daten.dabei || []
|
||||||
|
dabeiMine.value = !!daten.dabei_mine
|
||||||
ruhen()
|
ruhen()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fehler.value = err.message
|
fehler.value = err.message
|
||||||
@@ -83,6 +91,11 @@ async function oeffnen(item) {
|
|||||||
if (resp.ok) emit('open', await resp.json())
|
if (resp.ok) emit('open', await resp.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ich?.id,
|
||||||
|
() => laden_()
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await laden_()
|
await laden_()
|
||||||
await laufendenDrehHolen()
|
await laufendenDrehHolen()
|
||||||
@@ -162,7 +175,7 @@ defineExpose({ neu: laden_ })
|
|||||||
<p class="label">Der Film des Abends</p>
|
<p class="label">Der Film des Abends</p>
|
||||||
<h2>{{ gewinner.title }}</h2>
|
<h2>{{ gewinner.title }}</h2>
|
||||||
<p class="jahr">
|
<p class="jahr">
|
||||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
{{ gewinner.year }}<span v-if="gewinner.name"> · von <PersonName :name="gewinner.name" /></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +189,8 @@ defineExpose({ neu: laden_ })
|
|||||||
:merkliste="merkliste"
|
:merkliste="merkliste"
|
||||||
:mine="mine"
|
:mine="mine"
|
||||||
:gesehen-ids="gesehenIds"
|
:gesehen-ids="gesehenIds"
|
||||||
|
:dabei="dabei"
|
||||||
|
:dabei-mine="dabeiMine"
|
||||||
@open="oeffnen"
|
@open="oeffnen"
|
||||||
@fehler="fehler = $event"
|
@fehler="fehler = $event"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import PersonName from './PersonName.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
@@ -180,7 +181,7 @@ function datum(iso) {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text">{{ w.text }}</p>
|
<p class="text">{{ w.text }}</p>
|
||||||
<p class="meta">
|
<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) }}
|
<span class="punkt">·</span>{{ datum(w.created_at) }}
|
||||||
<template v-if="w.done_at">
|
<template v-if="w.done_at">
|
||||||
<span class="punkt">·</span>erledigt
|
<span class="punkt">·</span>erledigt
|
||||||
@@ -200,7 +201,7 @@ function datum(iso) {
|
|||||||
|
|
||||||
<div v-if="aufgeklappt.has(w.id)" class="notizen">
|
<div v-if="aufgeklappt.has(w.id)" class="notizen">
|
||||||
<p v-for="n in w.notes" :key="n.id" class="notiz">
|
<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>
|
<span class="notiz-text">{{ n.text }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="host"
|
v-if="host"
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export function useMeins({ host }) {
|
|||||||
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
|
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
|
||||||
else if (nachricht.type === 'users') ladeRunde()
|
else if (nachricht.type === 'users') ladeRunde()
|
||||||
else if (nachricht.type === 'features') ladeWuensche()
|
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