This commit is contained in:
team3
2026-09-16 13:21:50 +02:00
parent 90fffbe077
commit a639e40a23
3 changed files with 347 additions and 25 deletions

View File

@@ -541,12 +541,13 @@ def bewerten(
watched_id: int, watched_id: int,
response: Response, response: Response,
stars: int = Body(..., embed=True), stars: int = Body(..., embed=True),
text: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
stars = max(1, min(watched.MAX_STERNE, stars)) stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage # 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: if not eintrag:
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
events.senden("watched") events.senden("watched")

View File

@@ -34,11 +34,14 @@ CREATE TABLE IF NOT EXISTS ratings (
user_id INTEGER, user_id INTEGER,
stars INTEGER NOT NULL, stars INTEGER NOT NULL,
name TEXT, name TEXT,
text TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
""" """
MAX_STERNE = 5 MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_TEXT = 4000
def init(): def init():
@@ -52,6 +55,10 @@ def init():
conn.execute( conn.execute(
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0" "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(): def _jetzt():
@@ -65,8 +72,8 @@ def alle(session_id):
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC" "SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
).fetchall() ).fetchall()
stimmen = conn.execute( stimmen = conn.execute(
"SELECT id, watched_id, session_id, user_id, stars, name FROM ratings " "SELECT id, watched_id, session_id, user_id, stars, name, text "
"ORDER BY created_at ASC" "FROM ratings ORDER BY created_at ASC"
).fetchall() ).fetchall()
ich = besitzer(session_id) ich = besitzer(session_id)
@@ -78,23 +85,24 @@ def alle(session_id):
for film in filme: for film in filme:
eigene = je_film.get(film["id"], []) eigene = je_film.get(film["id"], [])
werte = [s["stars"] for s in eigene] werte = [s["stars"] for s in eigene]
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
out.append( out.append(
{ {
**dict(film), **dict(film),
"ratings": [ "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 for s in eigene
], ],
"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),
"mine": next( "mine": meine["stars"] if meine else None,
( "mine_text": meine["text"] if meine else None,
s["stars"]
for s in eigene
if gehoert_mir(s, session_id, ich)
),
None,
),
} }
) )
return out return out
@@ -148,34 +156,50 @@ def sichtbarkeit(watched_id, versteckt):
).rowcount ).rowcount
def bewerten(watched_id, session_id, stars, name=None): def bewerten(watched_id, session_id, stars, name=None, text=None):
"""Gibt None zurück, wenn der Film gar nicht in der Liste steht.""" """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: with connect() as conn:
da = conn.execute( da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,) "SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone() ).fetchone()
if not da: if not da:
return None return None
# Eine Stimme je Person — die eigene alte fliegt vorher raus
ich = besitzer(session_id) 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( conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}", f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id), (watched_id, ich, ich, session_id),
) )
conn.execute( conn.execute(
"INSERT INTO ratings " "INSERT INTO ratings "
"(watched_id, session_id, user_id, stars, name, created_at) " "(watched_id, session_id, user_id, stars, name, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?)",
( (
watched_id, watched_id,
session_id, session_id,
ich, ich,
stars, stars,
(name or "").strip()[:40] or None, (name or "").strip()[:40] or None,
text,
_jetzt(), _jetzt(),
), ),
) )
return {"watched_id": watched_id, "stars": stars} return {"watched_id": watched_id, "stars": stars, "text": text}
def stimme_loeschen(rating_id): def stimme_loeschen(rating_id):

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { posterUrl } from '../images' import { posterUrl } from '../images'
import { abonnieren } from '../live' import { abonnieren } from '../live'
@@ -17,6 +17,7 @@ const fehler = ref('')
const laden = ref(true) const laden = ref(true)
const STERNE = [1, 2, 3, 4, 5] const STERNE = [1, 2, 3, 4, 5]
const MAX_TEXT = 4000
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt // Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
const schwebt = ref({ id: null, wert: 0 }) const schwebt = ref({ id: null, wert: 0 })
@@ -41,7 +42,8 @@ async function laden_() {
} }
async function bewerten(item, sterne) { async function bewerten(item, sterne) {
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen // Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen.
// Ohne text im Body behält das Backend die bisherige Rezension.
item.mine = sterne item.mine = sterne
try { try {
await fetch(`/api/watched/${item.id}/rating`, { await fetch(`/api/watched/${item.id}/rating`, {
@@ -54,6 +56,73 @@ async function bewerten(item, sterne) {
} }
} }
// Die Rezension schreibt man in einem eigenen Fenster über der Seite —
// in der schmalen Karte ist kein Platz für einen längeren Text
const schreibt = ref(null)
const entwurf = ref('')
const sterneEntwurf = ref(0)
const schwebtImFenster = ref(0)
const speichert = ref(false)
const feld = ref(null)
async function rezensionOeffnen(item) {
schreibt.value = item
entwurf.value = item.mine_text || ''
sterneEntwurf.value = item.mine || 0
// autofocus zieht nur beim Seitenaufbau, das Fenster kommt später dazu
await nextTick()
feld.value?.focus()
}
function rezensionSchliessen() {
schreibt.value = null
schwebtImFenster.value = 0
}
async function rezensionSpeichern() {
const item = schreibt.value
if (!item || !sterneEntwurf.value) return
speichert.value = true
try {
const resp = await fetch(`/api/watched/${item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterneEntwurf.value, text: entwurf.value }),
})
if (!resp.ok) throw new Error()
rezensionSchliessen()
} catch {
fehler.value = 'Speichern hat nicht geklappt.'
} finally {
speichert.value = false
}
}
function fensterStern(stern) {
return stern <= (schwebtImFenster.value || sterneEntwurf.value)
}
function onKey(e) {
if (e.key === 'Escape' && schreibt.value) rezensionSchliessen()
}
// Die eigene Wertung steht vorn, Texte sind das Lesenswerte
function sortiert(ratings) {
return [...ratings].sort((a, b) => b.mine - a.mine)
}
// Lange Texte zeigen in der Karte nur zwei Zeilen, bis man sie aufklappt
const aufgeklappt = reactive(new Set())
function lang(text) {
return text.length > 160 || text.includes('\n')
}
function umschalten(id) {
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
else aufgeklappt.add(id)
}
async function sichtbarkeit(item) { async function sichtbarkeit(item) {
await fetch(`/api/watched/${item.id}`, { await fetch(`/api/watched/${item.id}`, {
method: 'PATCH', method: 'PATCH',
@@ -101,6 +170,7 @@ function datum(iso) {
onMounted(() => { onMounted(() => {
laden_() laden_()
document.addEventListener('keydown', onKey)
abbestellen = abonnieren((nachricht) => { abbestellen = abonnieren((nachricht) => {
if (nachricht.type !== 'watched') return if (nachricht.type !== 'watched') return
laden_() laden_()
@@ -108,7 +178,10 @@ onMounted(() => {
}) })
}) })
onUnmounted(() => abbestellen?.()) onUnmounted(() => {
document.removeEventListener('keydown', onKey)
abbestellen?.()
})
</script> </script>
<template> <template>
@@ -173,10 +246,35 @@ onUnmounted(() => abbestellen?.())
</button> </button>
</div> </div>
<!-- Erst nach den Sternen: ein Text ohne Note ergäbe keine Wertung -->
<button
v-if="item.mine"
type="button"
class="link"
@click="rezensionOeffnen(item)"
>
{{ item.mine_text ? 'Rezension bearbeiten' : 'Rezension schreiben' }}
</button>
<ul v-if="item.ratings.length" class="stimmen"> <ul v-if="item.ratings.length" class="stimmen">
<li v-for="r in item.ratings" :key="r.id"> <li v-for="r in sortiert(item.ratings)" :key="r.id">
<span class="wer">{{ r.name || 'Anonym' }}</span> <span class="kopf">
<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>
<span
v-if="r.text"
class="satz"
:class="{ gekuerzt: lang(r.text) && !aufgeklappt.has(r.id) }"
>„{{ r.text }}“</span>
<button
v-if="r.text && lang(r.text)"
type="button"
class="mehr"
@click="umschalten(r.id)"
>
{{ aufgeklappt.has(r.id) ? 'weniger' : 'mehr' }}
</button>
<button <button
v-if="host" v-if="host"
type="button" type="button"
@@ -217,6 +315,54 @@ onUnmounted(() => abbestellen?.())
</div> </div>
</article> </article>
</div> </div>
<!-- Das Schreibfenster liegt über der ganzen Seite, nicht in der Karte -->
<Teleport to="body">
<div v-if="schreibt" class="rezension-hintergrund" @click.self="rezensionSchliessen">
<div class="rezension" role="dialog" aria-modal="true">
<p class="label">Deine Rezension</p>
<h2>{{ schreibt.title }}</h2>
<div class="wertung gross" @mouseleave="schwebtImFenster = 0">
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: fensterStern(s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebtImFenster = s"
@click="sterneEntwurf = s"
>
★
</button>
</div>
<textarea
ref="feld"
v-model="entwurf"
rows="10"
:maxlength="MAX_TEXT"
placeholder="Was bleibt hängen? Was hat genervt? Ein Satz reicht, mehr geht auch."
></textarea>
<div class="fuss">
<span class="zaehler">{{ entwurf.length }} / {{ MAX_TEXT }}</span>
<button type="button" class="abbrechen" @click="rezensionSchliessen">
Abbrechen
</button>
<button
type="button"
class="speichern"
:disabled="speichert || !sterneEntwurf"
@click="rezensionSpeichern"
>
Speichern
</button>
</div>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
@@ -334,6 +480,19 @@ h3:hover {
color: #f5c518; 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 { .stimmen {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -344,10 +503,18 @@ h3:hover {
.stimmen li { .stimmen li {
display: flex; display: flex;
gap: 0.5rem; flex-wrap: wrap;
gap: 0.15rem 0.5rem;
align-items: baseline;
font-size: 0.76rem; font-size: 0.76rem;
} }
.kopf {
display: flex;
gap: 0.4rem;
align-items: baseline;
}
.wer { .wer {
color: var(--muted); color: var(--muted);
overflow: hidden; overflow: hidden;
@@ -355,10 +522,44 @@ h3:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.wer.eigen {
color: var(--accent);
font-weight: 600;
}
.punkte { .punkte {
color: #f5c518; 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 { .weg-klein {
padding: 0 0.25rem; padding: 0 0.25rem;
color: var(--muted); color: var(--muted);
@@ -419,4 +620,100 @@ h3:hover {
.note.error { .note.error {
color: var(--accent); 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;
}
</style> </style>