diff --git a/backend/app/main.py b/backend/app/main.py index 2be1023..bfec216 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -541,12 +541,13 @@ def bewerten( watched_id: int, response: Response, stars: int = Body(..., embed=True), + text: str | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) stars = max(1, min(watched.MAX_STERNE, stars)) # Der Name kommt aus der Runde, nicht aus der Anfrage - eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid)) + eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text) if not eintrag: raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") events.senden("watched") diff --git a/backend/app/watched.py b/backend/app/watched.py index 1c32319..d1c6ac1 100644 --- a/backend/app/watched.py +++ b/backend/app/watched.py @@ -34,11 +34,14 @@ CREATE TABLE IF NOT EXISTS ratings ( user_id INTEGER, stars INTEGER NOT NULL, name TEXT, + text TEXT, created_at TEXT NOT NULL ); """ MAX_STERNE = 5 +# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein +MAX_TEXT = 4000 def init(): @@ -52,6 +55,10 @@ def init(): conn.execute( "ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0" ) + # Ein Satz zur Wertung kam später dazu + spalten = {r["name"] for r in conn.execute("PRAGMA table_info(ratings)")} + if "text" not in spalten: + conn.execute("ALTER TABLE ratings ADD COLUMN text TEXT") def _jetzt(): @@ -65,8 +72,8 @@ def alle(session_id): "SELECT * FROM watched ORDER BY watched_at DESC, id DESC" ).fetchall() stimmen = conn.execute( - "SELECT id, watched_id, session_id, user_id, stars, name FROM ratings " - "ORDER BY created_at ASC" + "SELECT id, watched_id, session_id, user_id, stars, name, text " + "FROM ratings ORDER BY created_at ASC" ).fetchall() ich = besitzer(session_id) @@ -78,23 +85,24 @@ def alle(session_id): for film in filme: eigene = je_film.get(film["id"], []) werte = [s["stars"] for s in eigene] + meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None) out.append( { **dict(film), "ratings": [ - {"id": s["id"], "stars": s["stars"], "name": s["name"]} + { + "id": s["id"], + "stars": s["stars"], + "name": s["name"], + "text": s["text"], + "mine": gehoert_mir(s, session_id, ich), + } for s in eigene ], "average": round(sum(werte) / len(werte), 1) if werte else None, "count": len(werte), - "mine": next( - ( - s["stars"] - for s in eigene - if gehoert_mir(s, session_id, ich) - ), - None, - ), + "mine": meine["stars"] if meine else None, + "mine_text": meine["text"] if meine else None, } ) return out @@ -148,34 +156,50 @@ def sichtbarkeit(watched_id, versteckt): ).rowcount -def bewerten(watched_id, session_id, stars, name=None): - """Gibt None zurück, wenn der Film gar nicht in der Liste steht.""" +def bewerten(watched_id, session_id, stars, name=None, text=None): + """Sterne und ein optionaler Satz dazu. + + Kommt kein Text mit (None), bleibt der bisherige stehen — wer nur die + Sterne nachjustiert, verliert seinen Satz nicht. Ein leerer String + löscht ihn ausdrücklich. + + Gibt None zurück, wenn der Film gar nicht in der Liste steht. + """ with connect() as conn: da = conn.execute( "SELECT 1 FROM watched WHERE id = ?", (watched_id,) ).fetchone() if not da: return None - # Eine Stimme je Person — die eigene alte fliegt vorher raus ich = besitzer(session_id) + if text is None: + alt = conn.execute( + f"SELECT text FROM ratings WHERE watched_id = ? AND {WEM}", + (watched_id, ich, ich, session_id), + ).fetchone() + text = alt["text"] if alt else None + else: + text = text.strip()[:MAX_TEXT] or None + # Eine Stimme je Person — die eigene alte fliegt vorher raus conn.execute( f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}", (watched_id, ich, ich, session_id), ) conn.execute( "INSERT INTO ratings " - "(watched_id, session_id, user_id, stars, name, created_at) " - "VALUES (?, ?, ?, ?, ?, ?)", + "(watched_id, session_id, user_id, stars, name, text, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", ( watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, + text, _jetzt(), ), ) - return {"watched_id": watched_id, "stars": stars} + return {"watched_id": watched_id, "stars": stars, "text": text} def stimme_loeschen(rating_id): diff --git a/frontend/src/components/WatchedTab.vue b/frontend/src/components/WatchedTab.vue index ea7f300..148063d 100644 --- a/frontend/src/components/WatchedTab.vue +++ b/frontend/src/components/WatchedTab.vue @@ -1,5 +1,5 @@ @@ -334,6 +480,19 @@ h3:hover { color: #f5c518; } +.link { + align-self: flex-start; + padding: 0; + color: var(--muted); + font-size: 0.78rem; + text-decoration: underline; + text-underline-offset: 2px; +} + +.link:hover { + color: var(--text); +} + .stimmen { display: flex; flex-direction: column; @@ -344,10 +503,18 @@ h3:hover { .stimmen li { display: flex; - gap: 0.5rem; + flex-wrap: wrap; + gap: 0.15rem 0.5rem; + align-items: baseline; font-size: 0.76rem; } +.kopf { + display: flex; + gap: 0.4rem; + align-items: baseline; +} + .wer { color: var(--muted); overflow: hidden; @@ -355,10 +522,44 @@ h3:hover { text-overflow: ellipsis; } +.wer.eigen { + color: var(--accent); + font-weight: 600; +} + .punkte { color: #f5c518; } +/* Der Text ist das Lesenswerte — normale Textfarbe, eigene Zeile */ +.satz { + flex-basis: 100%; + color: var(--text); + font-size: 0.82rem; + line-height: 1.4; + white-space: pre-wrap; +} + +/* Zwei Zeilen in der Karte, der Rest hinter "mehr" */ +.satz.gekuerzt { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.mehr { + padding: 0; + color: var(--muted); + font-size: 0.76rem; + text-decoration: underline; + text-underline-offset: 2px; +} + +.mehr:hover { + color: var(--text); +} + .weg-klein { padding: 0 0.25rem; color: var(--muted); @@ -419,4 +620,100 @@ h3:hover { .note.error { color: var(--accent); } + +/* ── Schreibfenster ──────────────────────────────────────────────── */ +.rezension-hintergrund { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.75); +} + +.rezension { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: min(42rem, 100%); + padding: 1.5rem 1.75rem; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 14px; +} + +.label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.rezension h2 { + font-size: 1.35rem; + line-height: 1.2; +} + +.wertung.gross .stern { + font-size: 1.7rem; +} + +.rezension textarea { + width: 100%; + padding: 0.7rem 0.9rem; + color: var(--text); + font-family: inherit; + font-size: 0.95rem; + line-height: 1.55; + resize: vertical; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; +} + +.rezension textarea:focus { + outline: none; + border-color: var(--accent); +} + +.fuss { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.zaehler { + flex: 1; + color: var(--muted); + font-size: 0.78rem; +} + +.abbrechen, +.speichern { + padding: 0.55rem 1.1rem; + font-size: 0.88rem; + font-weight: 600; + border-radius: 8px; +} + +.abbrechen { + color: var(--muted); + border: 1px solid var(--line); +} + +.abbrechen:hover { + color: var(--text); +} + +.speichern { + color: #fff; + background: var(--accent); +} + +.speichern:disabled { + opacity: 0.5; + cursor: default; +}