Vorschlag umschaltbar, Wertungen einzeln löschbar

Wer einen Film aufs Rad gesetzt hat, kann ihn dort auch wieder runternehmen.
Im Raster tragen alle Filme auf dem Rad jetzt eine Marke, nicht nur der
eigene Vorschlag — so sieht man auf einen Blick, was schon vorgeschlagen
ist. Die Karten hören dafür am Live-Kanal mit.

Dazu: der Host löscht einzelne Wertungen, das Cover dockt oben an statt
mittig, und die Suche heißt schlicht "Globale Suche".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
This commit is contained in:
team3
2026-09-02 19:36:53 +02:00
parent b356a16529
commit 5777dd5908
8 changed files with 142 additions and 12 deletions

View File

@@ -315,6 +315,15 @@ def bewerten(
return eintrag return eintrag
@app.delete("/api/watched/rating/{rating_id}")
def wertung_loeschen(rating_id: int):
"""Der Host räumt eine einzelne Wertung weg."""
if not watched.stimme_loeschen(rating_id):
raise HTTPException(status_code=404, detail="Wertung nicht gefunden")
events.senden("watched")
return {"removed": 1}
@app.delete("/api/suggestions/alle") @app.delete("/api/suggestions/alle")
def runde_zuruecksetzen(): def runde_zuruecksetzen():
"""Host leert die Runde: Vorschläge weg, Rad wieder leer.""" """Host leert die Runde: Vorschläge weg, Rad wieder leer."""

View File

@@ -59,7 +59,7 @@ 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 watched_id, session_id, stars, name FROM ratings " "SELECT id, watched_id, session_id, stars, name FROM ratings "
"ORDER BY created_at ASC" "ORDER BY created_at ASC"
).fetchall() ).fetchall()
@@ -75,7 +75,8 @@ def alle(session_id):
{ {
**dict(film), **dict(film),
"ratings": [ "ratings": [
{"stars": s["stars"], "name": s["name"]} for s in eigene {"id": s["id"], "stars": s["stars"], "name": s["name"]}
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),
@@ -163,6 +164,14 @@ def bewerten(watched_id, session_id, stars, name=None):
return {"watched_id": watched_id, "stars": stars} return {"watched_id": watched_id, "stars": stars}
def stimme_loeschen(rating_id):
"""Der Host räumt eine einzelne Wertung weg."""
with connect() as conn:
return conn.execute(
"DELETE FROM ratings WHERE id = ?", (rating_id,)
).rowcount
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:

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from './live'
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 MovieDetail from './components/MovieDetail.vue' import MovieDetail from './components/MovieDetail.vue'
@@ -249,14 +250,19 @@ function tastatur(e) {
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen // Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null) const meinVorschlag = ref(null)
// Alle Filme auf dem Rad, damit die Karten es zeigen können
const aufDemRad = ref([])
async function ladeVorschlag() { async function ladeVorschlag() {
try { try {
const resp = await fetch('/api/suggestions') const resp = await fetch('/api/suggestions')
if (!resp.ok) return if (!resp.ok) return
const daten = await resp.json() const daten = await resp.json()
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
aufDemRad.value = daten.items.map((i) => i.movie_id)
} catch { } catch {
meinVorschlag.value = null meinVorschlag.value = null
aufDemRad.value = []
} }
} }
@@ -375,14 +381,23 @@ async function load() {
} }
} }
let abbestellen = null
onMounted(() => { onMounted(() => {
load() load()
ladeVorschlag() ladeVorschlag()
ladeGesehen() ladeGesehen()
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
})
window.addEventListener('keydown', tastatur) window.addEventListener('keydown', tastatur)
}) })
onUnmounted(() => window.removeEventListener('keydown', tastatur)) onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
abbestellen?.()
})
</script> </script>
<template> <template>
@@ -447,12 +462,14 @@ onUnmounted(() => window.removeEventListener('keydown', tastatur))
<MovieCard <MovieCard
v-if="entry.parts.length === 1" v-if="entry.parts.length === 1"
:movie="entry.parts[0]" :movie="entry.parts[0]"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
@open="selected = $event" @open="selected = $event"
/> />
<SeriesCard <SeriesCard
v-else v-else
:parts="entry.parts" :parts="entry.parts"
:start="entry.best" :start="entry.best"
:auf-dem-rad="aufDemRad"
@open="selected = $event" @open="selected = $event"
/> />
</template> </template>

View File

@@ -4,6 +4,7 @@ import { posterUrl } from '../images'
const props = defineProps({ const props = defineProps({
movie: { type: Object, required: true }, movie: { type: Object, required: true },
aufDemRad: { type: Boolean, default: false },
}) })
defineEmits(['open']) defineEmits(['open'])
@@ -21,7 +22,13 @@ const meta = computed(() =>
</script> </script>
<template> <template>
<button class="card" type="button" @click="$emit('open', movie)"> <button
class="card"
:class="{ 'auf-dem-rad': aufDemRad }"
type="button"
@click="$emit('open', movie)"
>
<span v-if="aufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
<div class="poster"> <div class="poster">
<img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" /> <img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" />
<span v-else class="placeholder">Kein Cover</span> <span v-else class="placeholder">Kein Cover</span>
@@ -37,6 +44,7 @@ const meta = computed(() =>
<style scoped> <style scoped>
.card { .card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -47,6 +55,27 @@ const meta = computed(() =>
transition: transform 0.15s ease, border-color 0.15s ease; transition: transform 0.15s ease, border-color 0.15s ease;
} }
/* Steht der Film auf dem Rad, soll man das im Raster sehen */
.card.auf-dem-rad {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.rad-marke {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 1;
padding: 0.1rem 0.4rem;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--accent);
border-radius: 4px;
}
.card:hover, .card:hover,
.card:focus-visible { .card:focus-visible {
transform: translateY(-4px); transform: translateY(-4px);

View File

@@ -19,6 +19,18 @@ const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id () => props.meinVorschlag?.movie_id === props.movie.id
) )
async function zuruecknehmen() {
vorschlagen.value = { zustand: 'laeuft' }
try {
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
if (!resp.ok) throw new Error()
vorschlagen.value = { zustand: 'bereit' }
emit('suggested')
} catch {
vorschlagen.value = { zustand: 'fehler' }
}
}
async function abschicken() { async function abschicken() {
vorschlagen.value = { zustand: 'laeuft' } vorschlagen.value = { zustand: 'laeuft' }
try { try {
@@ -136,11 +148,15 @@ onUnmounted(() => {
</div> </div>
<div class="vorschlag"> <div class="vorschlag">
<template v-if="vorschlagen.zustand === 'fertig'"> <template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
<span class="ok">Für den Filmabend vorgeschlagen.</span> <span class="ok">Steht auf dem Rad.</span>
</template> <button
<template v-else-if="schonVorgeschlagen"> type="button"
<span class="ok">Dieser Film steht schon auf dem Rad.</span> :disabled="vorschlagen.zustand === 'laeuft'"
@click="zuruecknehmen"
>
Wieder runternehmen
</button>
</template> </template>
<template v-else> <template v-else>
<span v-if="meinVorschlag" class="hinweis"> <span v-if="meinVorschlag" class="hinweis">

View File

@@ -54,7 +54,7 @@ async function open(movie) {
v-model="query" v-model="query"
type="search" type="search"
class="big-search" class="big-search"
placeholder="Film suchen — egal wo er läuft…" placeholder="Globale Suche …"
autofocus autofocus
/> />

View File

@@ -6,6 +6,7 @@ const props = defineProps({
parts: { type: Array, required: true }, parts: { type: Array, required: true },
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten // Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
start: { type: Number, default: 0 }, start: { type: Number, default: 0 },
aufDemRad: { type: Array, default: () => [] },
}) })
defineEmits(['open']) defineEmits(['open'])
@@ -17,6 +18,8 @@ const rating = computed(() =>
current.value.vote_average ? current.value.vote_average.toFixed(1) : null current.value.vote_average ? current.value.vote_average.toFixed(1) : null
) )
const stehtAufDemRad = computed(() => props.aufDemRad.includes(current.value.id))
const meta = computed(() => const meta = computed(() =>
[current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`] [current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`]
.filter(Boolean) .filter(Boolean)
@@ -30,7 +33,8 @@ function step(delta) {
</script> </script>
<template> <template>
<article class="card"> <article class="card" :class="{ 'auf-dem-rad': stehtAufDemRad }">
<span v-if="stehtAufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
<button class="poster" type="button" @click="$emit('open', current)"> <button class="poster" type="button" @click="$emit('open', current)">
<img v-if="poster" :src="poster" :alt="current.title" loading="lazy" /> <img v-if="poster" :src="poster" :alt="current.title" loading="lazy" />
<span v-else class="placeholder">Kein Cover</span> <span v-else class="placeholder">Kein Cover</span>
@@ -64,6 +68,7 @@ function step(delta) {
<style scoped> <style scoped>
.card { .card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -212,4 +217,24 @@ h2:hover {
.dot:hover { .dot:hover {
background: var(--muted); background: var(--muted);
} }
.card.auf-dem-rad {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.rad-marke {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 2;
padding: 0.1rem 0.4rem;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--accent);
border-radius: 4px;
}
</style> </style>

View File

@@ -62,6 +62,10 @@ async function sichtbarkeit(item) {
}) })
} }
async function wertungEntfernen(wertung) {
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
}
async function entfernen(item) { async function entfernen(item) {
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' }) await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
} }
@@ -170,9 +174,18 @@ onUnmounted(() => abbestellen?.())
</div> </div>
<ul v-if="item.ratings.length" class="stimmen"> <ul v-if="item.ratings.length" class="stimmen">
<li v-for="(r, i) in item.ratings" :key="i"> <li v-for="r in item.ratings" :key="r.id">
<span class="wer">{{ r.name || 'Anonym' }}</span> <span class="wer">{{ r.name || 'Anonym' }}</span>
<span class="punkte">{{ '★'.repeat(r.stars) }}</span> <span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<button
v-if="host"
type="button"
class="weg-klein"
title="Diese Wertung entfernen"
@click="wertungEntfernen(r)"
>
×
</button>
</li> </li>
</ul> </ul>
@@ -217,6 +230,7 @@ onUnmounted(() => abbestellen?.())
.karte { .karte {
display: flex; display: flex;
gap: 0.9rem; gap: 0.9rem;
align-items: flex-start;
padding: 0.8rem; padding: 0.8rem;
background: var(--bg-soft); background: var(--bg-soft);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -345,6 +359,17 @@ h3:hover {
color: #f5c518; color: #f5c518;
} }
.weg-klein {
padding: 0 0.25rem;
color: var(--muted);
font-size: 0.9rem;
line-height: 1;
}
.weg-klein:hover {
color: var(--accent);
}
.aktionen { .aktionen {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;