Files
horror/frontend/src/App.vue
team3 5777dd5908 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
2026-09-02 19:36:53 +02:00

633 lines
18 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from './live'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import MovieDetail from './components/MovieDetail.vue'
import SearchTab from './components/SearchTab.vue'
import StreamTab from './components/StreamTab.vue'
import WatchedTab from './components/WatchedTab.vue'
import WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.vue'
const movies = ref([])
const status = ref(null)
const loading = ref(true)
const error = ref('')
const search = ref('')
const sort = ref('rating')
const selected = ref(null)
// Nach dem Neuladen soll derselbe Reiter offen sein
const TAB_SCHLUESSEL = 'filmabend_tab'
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream']
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
const include = ref([])
const exclude = ref([])
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
const canonMovies = computed(() =>
movies.value.filter((m) => m.canon_group !== null)
)
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
]
const grenzen = computed(() => {
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const out = {}
for (const regler of REGLER) {
const werte = pool.map((m) => m[regler.feld]).filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
}
}
return out
})
const ranges = ref({})
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
watch(
grenzen,
(neu) => {
ranges.value = Object.fromEntries(
Object.entries(neu).map(([k, v]) => [k, [v.min, v.max]])
)
},
{ immediate: true }
)
function resetRanges() {
ranges.value = Object.fromEntries(
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
)
}
function imBereich(movie) {
for (const regler of REGLER) {
const spanne = ranges.value[regler.key]
const grenze = grenzen.value[regler.key]
if (!spanne) continue
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
const wert = movie[regler.feld]
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
}
return true
}
function toggleGenre(kind, genre) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const at = liste.value.indexOf(genre)
if (at === -1) {
liste.value = [...liste.value, genre]
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
andere.value = andere.value.filter((g) => g !== genre)
} else {
liste.value = liste.value.filter((g) => g !== genre)
}
}
function toggleAll(kind) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const alle = genreOptions.value.map((o) => o.genre)
// Sind schon alle drin, ist der zweite Klick ein Leeren
if (liste.value.length === alle.length) {
liste.value = []
} else {
liste.value = alle
andere.value = []
}
}
function resetGenres() {
include.value = []
exclude.value = []
resetRanges()
}
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
const genreOptions = computed(() => {
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const counts = new Map()
for (const movie of pool) {
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
}
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
return [...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
.map(([genre, count]) => ({ genre, count }))
})
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
function groupBy(movies, schluessel) {
const byKey = new Map()
const einzeln = []
for (const movie of movies) {
const key = schluessel(movie)
if (key === null || key === undefined) {
einzeln.push(movie)
continue
}
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key).push(movie)
}
const gruppen = []
for (const [key, parts] of byKey) {
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
if (parts.length === 1) einzeln.push(parts[0])
else gruppen.push({ group: key, parts })
}
return { gruppen, einzeln }
}
function sortiereReihe(parts) {
const sorted = [...parts].sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
)
// Die Karte startet beim stärksten Teil, danach sortiert auch die
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
let best = 0
sorted.forEach((part, i) => {
if (score(part) > score(sorted[best])) best = i
})
return { parts: sorted, best }
}
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
// Jahr — hier zählt das Erscheinungsjahr.
const canonGroups = computed(() => {
const byGroup = new Map()
for (const movie of canonMovies.value) {
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
byGroup.get(movie.canon_group).push(movie)
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
})
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
const netflixEntries = computed(() => {
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
return [
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
]
})
const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
{ id: 'search', label: 'Suche', count: null },
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'stream', label: 'Stream', count: null },
])
const rad = ref(null)
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
// ob der Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
try {
const resp = await fetch('/api/watched')
if (resp.ok) gesehen.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(
() => gesehen.value.filter((e) => host.value || !e.hidden).length
)
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
// Ein Name für alles: Vorschläge und Wertungen. Ohne Namen läuft es
// anonym, das ist ausdrücklich erlaubt.
const NAME_SCHLUESSEL = 'filmabend_name'
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
watch(name, (wert) => localStorage.setItem(NAME_SCHLUESSEL, wert))
const HOST_SCHLUESSEL = 'filmabend_host'
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
}
}
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null)
// Alle Filme auf dem Rad, damit die Karten es zeigen können
const aufDemRad = ref([])
async function ladeVorschlag() {
try {
const resp = await fetch('/api/suggestions')
if (!resp.ok) return
const daten = await resp.json()
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
aufDemRad.value = daten.items.map((i) => i.movie_id)
} catch {
meinVorschlag.value = null
aufDemRad.value = []
}
}
function nachVorschlag() {
ladeVorschlag()
rad.value?.neu()
}
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
const MIN_VOTES = 150
const average = computed(() => {
const rated = movies.value.filter((m) => m.vote_count)
if (!rated.length) return 0
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
})
function score(movie) {
const votes = movie.vote_count || 0
return (
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
)
}
const SORTS = {
rating: (a, b) => score(b) - score(a),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function matches(movie, term) {
return (
movie.title.toLowerCase().includes(term) ||
(movie.original_title || '').toLowerCase().includes(term)
)
}
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
function passt(movie) {
return hasGenre(movie) && imBereich(movie)
}
function hasGenre(movie) {
const genres = movie.genres || []
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
return false
}
return !include.value.length || genres.some((g) => include.value.includes(g))
}
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil
function filterGruppen(entries, term) {
return entries
.filter(
(e) =>
e.parts.some((m) => passt(m)) &&
(!term || e.parts.some((m) => matches(m, term)))
)
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
}
const visible = computed(() =>
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase())
)
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
const visibleCanon = computed(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
)
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
const series = computed(() => {
const movie = selected.value
if (!movie) return []
const group =
movie.canon_group !== null
? canonMovies.value.filter((m) => m.canon_group === movie.canon_group)
: movie.collection_id
? netflixMovies.value.filter((m) => m.collection_id === movie.collection_id)
: []
if (group.length < 2) return []
return [...group].sort((a, b) =>
(a.release_date || '').localeCompare(b.release_date || '')
)
})
const currentCount = computed(() =>
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
)
async function load() {
loading.value = true
error.value = ''
try {
const [moviesRes, statusRes] = await Promise.all([
fetch('/api/movies'),
fetch('/api/status'),
])
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
movies.value = await moviesRes.json()
status.value = await statusRes.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
let abbestellen = null
onMounted(() => {
load()
ladeVorschlag()
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)
})
onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
abbestellen?.()
})
</script>
<template>
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: tab.id === activeTab }"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
<input
v-model.trim="name"
type="text"
class="name"
placeholder="Dein Name"
maxlength="40"
/>
<span v-if="host" class="host">Host</span>
</nav>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
<WheelTab
v-else-if="activeTab === 'wheel'"
ref="rad"
:host="host"
@open="selected = $event"
@reset="meinVorschlag = null"
/>
<WatchedTab
v-else-if="activeTab === 'watched'"
:host="host"
:name="name"
@open="selected = $event"
@changed="ladeGesehen"
/>
<StreamTab v-else-if="activeTab === 'stream'" />
<div v-else class="layout">
<div class="results">
<p v-if="loading" class="note">Lade Katalog…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!movies.length" class="note">
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
</p>
<p v-else-if="!currentCount" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p>
<div class="grid">
<template
v-for="entry in activeTab === 'netflix' ? visible : visibleCanon"
:key="entry.group"
>
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
@open="selected = $event"
/>
<SeriesCard
v-else
:parts="entry.parts"
:start="entry.best"
:auf-dem-rad="aufDemRad"
@open="selected = $event"
/>
</template>
</div>
</div>
<FilterSidebar
v-if="genreOptions.length"
v-model:search="search"
v-model:sort="sort"
v-model:ranges="ranges"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<MovieDetail
v-if="selected"
:movie="selected"
:series="series"
@close="selected = null"
@open="selected = $event"
:mein-vorschlag="meinVorschlag"
:host="host"
:name="name"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
@suggested="nachVorschlag"
@watched="ladeGesehen"
/>
</div>
</template>
<style scoped>
.page {
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
padding: 1.5rem 1.25rem 3rem;
padding-right: calc(var(--sidebar) + 1.25rem);
}
/* Such- und Rad-Reiter haben keine Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
.tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
.tabs button {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1rem;
color: var(--muted);
font-size: 0.95rem;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tabs .name {
width: 11rem;
margin: 0 0 0.35rem auto;
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
}
.host {
align-self: center;
padding: 0.15rem 0.5rem;
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--accent);
border-radius: 4px;
}
.tabs .count {
padding: 0.1rem 0.45rem;
font-size: 0.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.layout {
min-width: 0;
}
.results {
min-width: 0;
}
.layout > :last-child {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: var(--sidebar);
overflow-y: auto;
background: var(--bg);
border-left: 1px solid var(--line);
}
@media (max-width: 900px) {
.page {
padding-right: 1.25rem;
}
/* Zu schmal für eine feste Leiste — die Filter wandern über die Kacheln */
.layout {
display: flex;
flex-direction: column;
}
.layout > :last-child {
position: static;
order: -1;
width: auto;
border: 1px solid var(--line);
border-radius: 12px;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>