This commit is contained in:
team3
2026-08-29 11:28:25 +02:00
parent 38d5a8cfae
commit f5aeaadc42
20 changed files with 1934 additions and 184 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horror auf Netflix</title>
<title>Filmabend</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,7 +1,10 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
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 WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.vue'
const movies = ref([])
@@ -12,14 +15,156 @@ const search = ref('')
const sort = ref('rating')
const selected = ref(null)
const activeTab = ref('netflix')
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)
)
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
// zusammen, damit die Karte durch ihre Teile blättern kann.
// 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)
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.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) {
@@ -28,30 +173,46 @@ const canonGroups = computed(() => {
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => {
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
const sorted = parts.sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
a.canon_pos - b.canon_pos
)
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt über
// den Platz der ganzen Reihe
let best = 0
sorted.forEach((part, i) => {
if (score(part) > score(sorted[best])) best = i
})
return { group, parts: sorted, best }
})
.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: 'Kanon', count: canonGroups.value.length },
{ id: 'search', label: 'Suche', count: null },
{ id: 'wheel', label: 'Rad', count: null },
])
const rad = ref(null)
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null)
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
} catch {
meinVorschlag.value = null
}
}
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
@@ -84,25 +245,42 @@ function matches(movie, term) {
)
}
const visible = computed(() => {
const term = search.value.trim().toLowerCase()
const list = term
? netflixMovies.value.filter((m) => matches(m, term))
: [...netflixMovies.value]
return list.sort(SORTS[sort.value])
})
// 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(() => {
const term = search.value.trim().toLowerCase()
const list = term
? canonGroups.value.filter((g) => g.parts.some((m) => matches(m, term)))
: [...canonGroups.value]
return list.sort((a, b) =>
SORTS[sort.value](a.parts[a.best], b.parts[b.best])
)
})
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.
@@ -127,14 +305,6 @@ const currentCount = computed(() =>
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
)
const lastSync = computed(() => {
if (!status.value?.last_sync) return null
return new Date(status.value.last_sync).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
})
async function load() {
loading.value = true
error.value = ''
@@ -153,27 +323,14 @@ async function load() {
}
}
onMounted(load)
onMounted(() => {
load()
ladeVorschlag()
})
</script>
<template>
<div class="page">
<header class="top">
<div class="brand">
<h1>Horrorfilme</h1>
<p v-if="lastSync" class="sync">Stand {{ lastSync }}</p>
</div>
<div class="controls">
<input v-model="search" type="search" placeholder="Titel suchen…" />
<select v-model="sort">
<option value="rating">Beste Bewertung</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
<option value="title">Titel A–Z</option>
</select>
</div>
</header>
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
<nav class="tabs">
<button
v-for="tab in tabs"
@@ -183,79 +340,90 @@ onMounted(load)
@click="activeTab = tab.id"
>
{{ tab.label }}
<span class="count">{{ tab.count }}</span>
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
</nav>
<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 für „{{ search }}“.</p>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
<div v-if="activeTab === 'netflix'" class="grid">
<MovieCard
v-for="movie in visible"
:key="movie.id"
:movie="movie"
@open="selected = $event"
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
<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]"
@open="selected = $event"
/>
<SeriesCard
v-else
:parts="entry.parts"
:start="entry.best"
@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>
<div v-else class="grid">
<template v-for="entry in visibleCanon" :key="entry.group">
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
@open="selected = $event"
/>
<SeriesCard
v-else
:parts="entry.parts"
:start="entry.best"
@open="selected = $event"
/>
</template>
</div>
<MovieDetail
v-if="selected"
:movie="selected"
:series="series"
@close="selected = null"
@open="selected = $event"
:mein-vorschlag="meinVorschlag"
@suggested="nachVorschlag"
/>
<footer>
Daten von
<a href="https://www.themoviedb.org" target="_blank" rel="noopener">TMDB</a>
und JustWatch. This product uses the TMDB API but is not endorsed or certified
by TMDB.
</footer>
</div>
</template>
<style scoped>
.page {
max-width: 1400px;
margin: 0 auto;
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
padding: 1.5rem 1.25rem 3rem;
padding-right: calc(var(--sidebar) + 1.25rem);
}
.top {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1rem;
/* Such- und Rad-Reiter haben keine Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
.tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 1.75rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
@@ -286,29 +454,45 @@ onMounted(load)
border-radius: 20px;
}
h1 {
font-size: 1.6rem;
letter-spacing: -0.01em;
}
.sync {
margin-top: 0.3rem;
color: var(--muted);
font-size: 0.85rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
/* Ohne min-width schiebt das Suchfeld auf schmalen Fenstern die Seite breit */
.controls input {
flex: 1 1 12rem;
.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));
@@ -325,13 +509,4 @@ h1 {
color: var(--accent);
}
footer {
margin-top: 3rem;
padding-top: 1.25rem;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.6;
text-align: center;
border-top: 1px solid var(--line);
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup>
import { computed } from 'vue'
import RangeFilter from './RangeFilter.vue'
const props = defineProps({
options: { type: Array, required: true },
include: { type: Array, required: true },
exclude: { type: Array, required: true },
regler: { type: Array, default: () => [] },
grenzen: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['toggle', 'all', 'reset'])
const search = defineModel('search', { type: String, default: '' })
const sort = defineModel('sort', { type: String, default: 'rating' })
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
// Auch ein verschobener Regler zählt als aktiver Filter
const etwasAktiv = computed(
() =>
props.include.length > 0 ||
props.exclude.length > 0 ||
props.regler.some((r) => {
const spanne = ranges.value[r.key]
const grenze = props.grenzen[r.key]
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
})
)
function beschriften(regler) {
return (wert) => {
// Jahreszahlen ohne Tausenderpunkt — sonst steht dort "2.026"
const zahl = regler.nachkomma
? wert.toFixed(regler.nachkomma)
: regler.roh
? String(wert)
: wert.toLocaleString('de-DE')
return zahl + (regler.einheit || '')
}
}
</script>
<template>
<aside class="sidebar">
<div class="controls">
<input v-model="search" type="search" placeholder="Titel suchen…" />
<select v-model="sort">
<option value="rating">Beste Bewertung</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
<option value="title">Titel A–Z</option>
</select>
</div>
<div class="head">
<h3>Filter</h3>
<button v-if="etwasAktiv" type="button" class="reset" @click="emit('reset')">
zurücksetzen
</button>
</div>
<section v-if="regler.length" class="regler-block">
<RangeFilter
v-for="eintrag in regler"
:key="eintrag.key"
:model-value="ranges[eintrag.key] || [0, 0]"
:label="eintrag.label"
:min="grenzen[eintrag.key]?.min ?? 0"
:max="grenzen[eintrag.key]?.max ?? 0"
:step="eintrag.step"
:gestaucht="!!eintrag.gestaucht"
:format="beschriften(eintrag)"
@update:model-value="ranges = { ...ranges, [eintrag.key]: $event }"
/>
</section>
<section>
<h4>Genres zeigen</h4>
<div class="badges">
<button
type="button"
class="badge alle"
:class="{ on: include.length === options.length }"
@click="emit('all', 'include')"
>
alle
</button>
<button
v-for="option in options"
:key="'in-' + option.genre"
type="button"
class="badge"
:class="{ on: include.includes(option.genre) }"
@click="emit('toggle', 'include', option.genre)"
>
{{ option.genre }}
<span class="count">{{ option.count }}</span>
</button>
</div>
</section>
<section>
<h4>Genres ausblenden</h4>
<div class="badges">
<button
type="button"
class="badge alle"
:class="{ on: exclude.length === options.length }"
@click="emit('all', 'exclude')"
>
alle
</button>
<button
v-for="option in options"
:key="'out-' + option.genre"
type="button"
class="badge out"
:class="{ on: exclude.includes(option.genre) }"
@click="emit('toggle', 'exclude', option.genre)"
>
{{ option.genre }}
<span class="count">{{ option.count }}</span>
</button>
</div>
</section>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1rem 0.9rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid var(--line);
}
.controls input,
.controls select {
width: 100%;
padding: 0.5rem 0.7rem;
font-size: 0.88rem;
}
.regler-block {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid var(--line);
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
h3 {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h4 {
margin-bottom: 0.5rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.reset {
color: var(--muted);
font-size: 0.75rem;
text-decoration: underline;
}
.reset:hover {
color: var(--text);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.badge {
display: flex;
align-items: center;
gap: 0.32rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.badge:hover {
color: var(--text);
border-color: var(--muted);
}
.count {
font-size: 0.7rem;
opacity: 0.6;
}
.badge.alle {
font-style: italic;
}
.badge.on {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.badge.out.on {
background: #4a4a56;
border-color: #4a4a56;
text-decoration: line-through;
}
</style>

View File

@@ -12,6 +12,12 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const rating = computed(() =>
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
)
const meta = computed(() =>
[props.movie.year || 'Jahr unbekannt', props.movie.runtime && `${props.movie.runtime} Min.`]
.filter(Boolean)
.join(' · ')
)
</script>
<template>
@@ -23,7 +29,7 @@ const rating = computed(() =>
</div>
<div class="body">
<h2>{{ movie.title }}</h2>
<p class="meta">{{ movie.year || 'Jahr unbekannt' }}</p>
<p class="meta">{{ meta }}</p>
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
</div>
</button>
@@ -87,6 +93,14 @@ const rating = computed(() =>
}
h2 {
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
unterschiedlich weit nach unten */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
min-height: calc(3 * 1.3em);
font-size: 1rem;
line-height: 1.3;
}
@@ -102,6 +116,7 @@ h2 {
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
min-height: calc(4 * 1.45em);
color: #b9b9c6;
font-size: 0.85rem;
line-height: 1.45;

View File

@@ -1,13 +1,39 @@
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { backdropUrl, posterUrl } from '../images'
const props = defineProps({
movie: { type: Object, required: true },
series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null },
})
const emit = defineEmits(['close', 'open'])
const emit = defineEmits(['close', 'open', 'suggested'])
const vorschlagen = ref({ zustand: 'bereit', name: '' })
const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id
)
async function abschicken() {
vorschlagen.value = { ...vorschlagen.value, zustand: 'laeuft' }
try {
const resp = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
movie_id: props.movie.id,
name: vorschlagen.value.name || null,
}),
})
if (!resp.ok) throw new Error()
vorschlagen.value = { ...vorschlagen.value, zustand: 'fertig' }
emit('suggested')
} catch {
vorschlagen.value = { ...vorschlagen.value, zustand: 'fehler' }
}
}
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const shots = computed(() =>
@@ -86,6 +112,37 @@ onUnmounted(() => {
IMDb
</a>
</div>
<div class="vorschlag">
<template v-if="vorschlagen.zustand === 'fertig'">
<span class="ok">Für den Filmabend vorgeschlagen.</span>
</template>
<template v-else-if="schonVorgeschlagen">
<span class="ok">Dieser Film steht schon auf dem Rad.</span>
</template>
<template v-else>
<span v-if="meinVorschlag" class="hinweis">
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
Vorschlag ersetzt ihn.
</span>
<input
v-model="vorschlagen.name"
type="text"
placeholder="Dein Name (optional)"
maxlength="40"
/>
<button
type="button"
:disabled="vorschlagen.zustand === 'laeuft'"
@click="abschicken"
>
Vorschlagen
</button>
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
Hat nicht geklappt.
</span>
</template>
</div>
</div>
</header>
@@ -257,6 +314,57 @@ h2 {
background: #f5c518;
}
.vorschlag {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.vorschlag input {
width: 11rem;
padding: 0.45rem 0.7rem;
font-size: 0.85rem;
}
.vorschlag button {
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 7px;
}
.vorschlag button:hover:not(:disabled) {
border-color: var(--accent);
}
.vorschlag button:disabled {
color: var(--muted);
cursor: default;
}
.hinweis {
flex-basis: 100%;
color: var(--muted);
font-size: 0.8rem;
line-height: 1.4;
}
.ok {
color: #6bbf7a;
font-size: 0.88rem;
}
.fehler {
color: var(--accent);
font-size: 0.85rem;
}
.netflix:hover,
.imdb:hover {
filter: brightness(1.15);

View File

@@ -0,0 +1,159 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: { type: String, required: true },
min: { type: Number, required: true },
max: { type: Number, required: true },
step: { type: Number, default: 1 },
// Bei den Stimmen liegt der Median weit unter dem Maximum. Linear säßen
// fast alle Filme im linken Prozent des Reglers.
gestaucht: { type: Boolean, default: false },
format: { type: Function, default: (v) => String(v) },
})
const value = defineModel({ type: Array, required: true })
const SCHRITTE = 1000
function zuRegler(wert) {
if (props.max === props.min) return 0
const anteil = (wert - props.min) / (props.max - props.min)
return Math.round((props.gestaucht ? Math.sqrt(anteil) : anteil) * SCHRITTE)
}
function zuWert(pos) {
const anteil = pos / SCHRITTE
const roh = props.min + (props.gestaucht ? anteil ** 2 : anteil) * (props.max - props.min)
return Math.round(roh / props.step) * props.step
}
const von = computed({
get: () => zuRegler(value.value[0]),
set: (pos) => {
const neu = Math.min(zuWert(Number(pos)), value.value[1])
value.value = [neu, value.value[1]]
},
})
const bis = computed({
get: () => zuRegler(value.value[1]),
set: (pos) => {
const neu = Math.max(zuWert(Number(pos)), value.value[0])
value.value = [value.value[0], neu]
},
})
const aktiv = computed(
() => value.value[0] > props.min || value.value[1] < props.max
)
</script>
<template>
<div class="range" :class="{ aktiv }">
<div class="kopf">
<span class="label">{{ label }}</span>
<span class="wert">{{ format(value[0]) }} – {{ format(value[1]) }}</span>
</div>
<div class="regler">
<input v-model="von" type="range" :min="0" :max="SCHRITTE" />
<input v-model="bis" type="range" :min="0" :max="SCHRITTE" />
</div>
</div>
</template>
<style scoped>
.range {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.kopf {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.78rem;
}
.label {
color: var(--muted);
}
.wert {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.aktiv .wert {
color: var(--accent);
font-weight: 600;
}
/* Beide Griffe liegen übereinander, sonst bräuchte es zwei Zeilen */
.regler {
position: relative;
height: 1.1rem;
}
.regler input {
position: absolute;
left: 0;
width: 100%;
height: 1.1rem;
margin: 0;
padding: 0;
background: none;
border: none;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.regler input:focus {
outline: none;
}
.regler input::-webkit-slider-runnable-track {
height: 3px;
background: var(--line);
border-radius: 2px;
}
.regler input::-moz-range-track {
height: 3px;
background: var(--line);
border-radius: 2px;
}
.regler input::-webkit-slider-thumb {
width: 0.85rem;
height: 0.85rem;
margin-top: -0.3rem;
background: var(--text);
border: none;
border-radius: 50%;
pointer-events: auto;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.regler input::-moz-range-thumb {
width: 0.85rem;
height: 0.85rem;
background: var(--text);
border: none;
border-radius: 50%;
pointer-events: auto;
cursor: pointer;
}
.aktiv .regler input::-webkit-slider-thumb {
background: var(--accent);
}
.aktiv .regler input::-moz-range-thumb {
background: var(--accent);
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup>
import { ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
const emit = defineEmits(['open'])
const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref('')
const searched = ref(false)
let timer = null
let laufende = 0
async function run(term) {
if (term.trim().length < 2) {
results.value = []
searched.value = false
return
}
const meine = ++laufende
loading.value = true
error.value = ''
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const data = await resp.json()
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return
results.value = data
searched.value = true
} catch (err) {
if (meine === laufende) error.value = err.message
} finally {
if (meine === laufende) loading.value = false
}
}
watch(query, (term) => {
clearTimeout(timer)
timer = setTimeout(() => run(term), 350)
})
async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="search-tab">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Film suchen — egal wo er läuft…"
autofocus
/>
<p v-if="loading" class="note">Suche läuft…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="searched && !results.length" class="note">
Nichts gefunden für „{{ query }}“.
</p>
<p v-else-if="!searched" class="note">
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
der Katalog.
</p>
<div class="grid">
<MovieCard
v-for="movie in results"
:key="movie.id"
:movie="movie"
@open="open"
/>
</div>
</div>
</template>
<style scoped>
.big-search {
width: 100%;
margin-bottom: 1.5rem;
padding: 0.9rem 1.1rem;
font-size: 1.05rem;
}
.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>

View File

@@ -17,6 +17,12 @@ const rating = computed(() =>
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
)
const meta = computed(() =>
[current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`]
.filter(Boolean)
.join(' · ')
)
function step(delta) {
const count = props.parts.length
index.value = (index.value + delta + count) % count
@@ -50,7 +56,7 @@ function step(delta) {
<div class="body">
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
<p class="meta">{{ current.year || 'Jahr unbekannt' }}</p>
<p class="meta">{{ meta }}</p>
<p class="teaser">{{ current.teaser || current.overview }}</p>
</div>
</article>
@@ -123,6 +129,14 @@ function step(delta) {
}
h2 {
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
unterschiedlich weit nach unten */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
min-height: calc(3 * 1.3em);
font-size: 1rem;
line-height: 1.3;
cursor: pointer;
@@ -143,6 +157,7 @@ h2:hover {
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
min-height: calc(4 * 1.45em);
flex: 1;
color: #b9b9c6;
font-size: 0.85rem;

View File

@@ -0,0 +1,433 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
const emit = defineEmits(['open'])
const items = ref([])
const mine = ref(null)
const laden = ref(true)
const fehler = ref('')
const winkel = ref(0)
const dreht = ref(false)
const gewinner = ref(null)
const dauer = ref(4.2)
const GROESSE = 420
const MITTE = GROESSE / 2
const RADIUS = MITTE - 6
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
// verschwimmt der Zeiger mit dem Rad
const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
]
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let quelle = null
function verbinden() {
quelle = new EventSource('/api/events')
quelle.onmessage = (ereignis) => {
const nachricht = JSON.parse(ereignis.data)
if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data)
}
// Der Browser verbindet nach einem Abbruch von selbst neu
}
function abspielen(ergebnis, uebersprungen = 0) {
gewinner.value = null
dreht.value = true
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
if (uebersprungen > 0) {
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
dauer.value = rest
} else {
dauer.value = ergebnis.dauer
}
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
window.setTimeout(() => {
dreht.value = false
gewinner.value = ergebnis.gewinner
}, rest * 1000)
}
async function laufendenDrehHolen() {
try {
const resp = await fetch('/api/spin')
if (!resp.ok) return
const daten = await resp.json()
if (!daten.result) return
if (daten.running) {
abspielen(daten.result, daten.elapsed)
} else {
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
dauer.value = 0
winkel.value = daten.result.winkel
gewinner.value = daten.result.gewinner
}
} catch {
/* dann eben ohne */
}
}
async function laden_() {
laden.value = true
fehler.value = ''
try {
const resp = await fetch('/api/suggestions')
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
mine.value = daten.mine
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => quelle?.close())
defineExpose({ neu: laden_ })
const segmente = computed(() => {
const n = items.value.length
if (!n) return []
const schritt = 360 / n
return items.value.map((item, i) => {
const start = i * schritt
const ende = start + schritt
const mitte = start + schritt / 2
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
// von außen nach innen
const gespiegelt = mitte > 180
return {
item,
farbe: FARBEN[i % FARBEN.length],
pfad: kreisstueck(start, ende),
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
gespiegelt,
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
breit: schritt > 9,
kurz: kuerzen(item.title, schritt),
}
})
})
function kuerzen(titel, schritt) {
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
}
function punkt(grad) {
const rad = ((grad - 90) * Math.PI) / 180
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
}
function kreisstueck(von, bis) {
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
if (bis - von >= 360) {
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
MITTE + RADIUS
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
}
const [x1, y1] = punkt(von)
const [x2, y2] = punkt(bis)
const gross = bis - von > 180 ? 1 : 0
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
}
async function drehen() {
if (dreht.value || !items.value.length) return
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
try {
const resp = await fetch('/api/spin', { method: 'POST' })
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
} catch {
fehler.value = 'Drehen hat nicht geklappt.'
}
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="wheel-tab">
<div class="buehne">
<p v-if="laden" class="note">Lade Vorschläge…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!items.length" class="note">
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
</p>
<template v-else>
<div class="rad-halter">
<div class="zeiger"></div>
<svg
class="rad"
:class="{ dreht }"
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
:style="{
transform: `rotate(${winkel}deg)`,
transitionDuration: `${dauer}s`,
}"
>
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
<text
v-if="seg.breit"
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
:y="MITTE + 4"
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
class="beschriftung"
>
{{ seg.kurz }}
</text>
</g>
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
</svg>
</div>
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
{{ dreht ? 'dreht…' : 'Drehen' }}
</button>
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
<img
v-if="posterUrl(gewinner.poster_path, 'w154')"
:src="posterUrl(gewinner.poster_path, 'w154')"
:alt="gewinner.title"
/>
<div>
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
</p>
</div>
</div>
</template>
</div>
<aside v-if="items.length" class="liste">
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
type="button"
class="eintrag"
:class="{ eigen: item.id === mine }"
@click="oeffnen(item)"
>
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
<span class="titel">{{ item.title }}</span>
<span class="von">{{ item.name || '—' }}</span>
</button>
</li>
</ul>
</aside>
</div>
</template>
<style scoped>
.wheel-tab {
display: grid;
grid-template-columns: 1fr 280px;
gap: 2rem;
align-items: start;
}
.buehne {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
}
.rad-halter {
position: relative;
width: min(420px, 80vw);
}
.rad {
display: block;
width: 100%;
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
transition-property: transform;
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
}
.beschriftung {
fill: #fff;
font-size: 13px;
font-weight: 600;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.45);
stroke-width: 3px;
}
.zeiger {
position: absolute;
top: -6px;
left: 50%;
z-index: 2;
width: 0;
height: 0;
margin-left: -11px;
border-top: 20px solid var(--text);
border-right: 11px solid transparent;
border-left: 11px solid transparent;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.dreh:disabled {
background: var(--line);
color: var(--muted);
cursor: default;
}
.gewinner {
display: flex;
gap: 1rem;
align-items: center;
max-width: 420px;
padding: 0.9rem;
text-align: left;
cursor: pointer;
background: var(--bg-soft);
border: 1px solid var(--accent);
border-radius: 12px;
}
.gewinner img {
width: 74px;
border-radius: 6px;
}
.label {
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.gewinner h2 {
margin: 0.2rem 0;
font-size: 1.15rem;
line-height: 1.25;
}
.jahr {
color: var(--muted);
font-size: 0.85rem;
}
.liste h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.liste ul {
display: flex;
flex-direction: column;
gap: 0.2rem;
list-style: none;
}
.eintrag {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.5rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 6px;
}
.eintrag:hover {
background: var(--bg-soft);
}
.eintrag.eigen .titel {
color: var(--accent);
font-weight: 600;
}
.punkt {
width: 0.6rem;
height: 0.6rem;
flex-shrink: 0;
border-radius: 50%;
}
.titel {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.von {
color: var(--muted);
font-size: 0.76rem;
}
.note {
padding: 3rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 900px) {
.wheel-tab {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,6 +5,7 @@
--text: #ececf1;
--muted: #8b8b99;
--accent: #e50914;
--sidebar: 310px;
color-scheme: dark;
}