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

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
@@ -17,6 +17,7 @@ const fehler = ref('')
const laden = ref(true)
const STERNE = [1, 2, 3, 4, 5]
const MAX_TEXT = 4000
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
const schwebt = ref({ id: null, wert: 0 })
@@ -41,7 +42,8 @@ async function laden_() {
}
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
try {
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) {
await fetch(`/api/watched/${item.id}`, {
method: 'PATCH',
@@ -101,6 +170,7 @@ function datum(iso) {
onMounted(() => {
laden_()
document.addEventListener('keydown', onKey)
abbestellen = abonnieren((nachricht) => {
if (nachricht.type !== 'watched') return
laden_()
@@ -108,7 +178,10 @@ onMounted(() => {
})
})
onUnmounted(() => abbestellen?.())
onUnmounted(() => {
document.removeEventListener('keydown', onKey)
abbestellen?.()
})
</script>
<template>
@@ -173,10 +246,35 @@ onUnmounted(() => abbestellen?.())
</button>
</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">
<li v-for="r in item.ratings" :key="r.id">
<span class="wer">{{ r.name || 'Anonym' }}</span>
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<li v-for="r in sortiert(item.ratings)" :key="r.id">
<span class="kopf">
<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
v-if="host"
type="button"
@@ -217,6 +315,54 @@ onUnmounted(() => abbestellen?.())
</div>
</article>
</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>
</template>
@@ -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;
}
</style>