This commit is contained in:
team3
2026-09-08 17:57:51 +02:00
parent 4de5018409
commit 13df86e9fc
12 changed files with 1088 additions and 90 deletions

View File

@@ -3,6 +3,7 @@ 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 AehnlichTab from './components/AehnlichTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue'
@@ -20,6 +21,10 @@ const loading = ref(true)
const error = ref('')
const search = ref('')
const sort = ref('random')
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
// "More like this" zeigt. Im Katalog gibt es die nicht.
const suchSort = ref('relevanz')
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
@@ -27,11 +32,29 @@ const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
// Bilder kommen gleich danach.
//
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
async function filmOeffnen(movie) {
selected.value = movie
if (!movie?.id) return
try {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
} catch {
/* dann eben mit den Listendaten */
}
}
// Nach dem Neuladen soll derselbe Reiter offen sein
const TAB_SCHLUESSEL = 'filmabend_tab'
const TAB_IDS = [
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche',
'verwaltung', 'stream',
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
'wuensche', 'verwaltung', 'stream',
]
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
@@ -54,7 +77,25 @@ const REGLER = [
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 50, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
}
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
'Western',
].map((genre) => ({ genre, count: null }))
const grenzen = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const out = {}
for (const regler of REGLER) {
@@ -135,6 +176,7 @@ function resetGenres() {
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
const genreOptions = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const counts = new Map()
for (const movie of pool) {
@@ -213,6 +255,7 @@ 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: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
...(host.value
? []
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
@@ -223,6 +266,30 @@ const tabs = computed(() => [
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
])
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
const suchFilter = computed(() => {
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
dauer: spanne('runtime'),
jahr: spanne('year'),
note: spanne('vote_average'),
grenzen: grenzen.value,
}
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
const aehnlichStart = ref(null)
function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie
selected.value = null
activeTab.value = 'aehnlich'
}
const rad = ref(null)
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
@@ -515,7 +582,7 @@ onUnmounted(() => {
<template>
<div class="page" :class="{
breit: [
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
].includes(activeTab),
}">
<nav class="tabs">
@@ -533,7 +600,39 @@ onUnmounted(() => {
<span v-else class="host">Host</span>
</nav>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
<SearchTab
v-if="activeTab === 'search'"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
<AehnlichTab
v-else
:filter="suchFilter"
:ansicht="ansicht"
:start="aehnlichStart"
@open="filmOeffnen"
@gestartet="aehnlichStart = null"
/>
<FilterSidebar
v-model:sort="suchSort"
v-model:ansicht="ansicht"
v-model:ranges="ranges"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:ohne-suche="true"
:mit-relevanz="true"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<WheelTab
v-else-if="activeTab === 'wheel'"
@@ -591,7 +690,7 @@ onUnmounted(() => {
:movie="entry.parts[0]"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht"
@open="selected = $event"
@open="filmOeffnen"
/>
<SeriesCard
v-else
@@ -599,7 +698,7 @@ onUnmounted(() => {
:start="entry.best"
:auf-dem-rad="aufDemRad"
:ansicht="ansicht"
@open="selected = $event"
@open="filmOeffnen"
/>
</template>
</div>
@@ -631,7 +730,7 @@ onUnmounted(() => {
:movie="selected"
:series="series"
@close="selected = null"
@open="selected = $event"
@open="filmOeffnen"
:mein-vorschlag="meinVorschlag"
:host="host"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
@@ -639,6 +738,7 @@ onUnmounted(() => {
@gemerkt="ladeMerkliste"
@suggested="nachVorschlag"
@watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
/>
</div>
@@ -662,10 +762,11 @@ onUnmounted(() => {
z-index: 10;
display: flex;
gap: 0.35rem;
align-items: stretch;
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
Karten oberhalb der Leiste durchs Bild */
margin: -1.5rem -1.25rem 1.25rem;
padding: 0.7rem 1.25rem 0;
padding: 0 1.25rem;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
@@ -694,7 +795,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1rem;
padding: 0.8rem 1rem 0.6rem;
color: var(--muted);
font-size: 0.95rem;
border-bottom: 2px solid transparent;

View File

@@ -0,0 +1,288 @@
<script setup>
import { ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig
const TREFFER = 20
const props = defineProps({
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
start: { type: Object, default: null },
})
const emit = defineEmits(['open', 'gestartet'])
const query = ref('')
const vorschlaege = ref([])
const gewaehlt = ref(null)
const treffer = ref([])
const laden = ref(false)
const fehler = ref('')
let timer = null
let filterTimer = null
let laufende = 0
let laufendeWahl = 0
let laufendeTreffer = 0
// Erst suchen, dann einen Film auswählen — danach zeigt der Reiter, was
// dazu passt.
async function suchen(term) {
if (term.trim().length < 2) {
vorschlaege.value = []
return
}
const meine = ++laufende
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const daten = await resp.json()
if (meine !== laufende) return
vorschlaege.value = daten.slice(0, 8)
} catch (err) {
if (meine === laufende) fehler.value = err.message
}
}
async function waehlen(film) {
const meine = ++laufendeWahl
gewaehlt.value = film
vorschlaege.value = []
query.value = ''
nachladen()
// Den vollen Datensatz holen, damit die erste Karte alles zeigt.
// Läuft neben den Ähnlichen her, nicht davor.
try {
const resp = await fetch(`/api/search/${film.id}`)
if (resp.ok && meine === laufendeWahl) gewaehlt.value = await resp.json()
} catch {
/* dann bleibt der Datensatz aus der Trefferliste */
}
}
// Die Filter gehen ans Backend: /recommendations kennt sie nicht, also
// blättert es dort so lange nach, bis zwanzig passende beisammen sind.
async function nachladen() {
const film = gewaehlt.value
if (!film) return
const meine = ++laufendeTreffer
// Erst wegräumen: sonst stehen unter der neuen Überschrift noch die
// Treffer von vorhin, bis die Antwort da ist
treffer.value = []
laden.value = true
fehler.value = ''
try {
const params = new URLSearchParams({
limit: TREFFER,
voll: 'true',
...filterParameter(props.filter),
})
const resp = await fetch(`/api/movies/${film.id}/aehnliche?${params}`)
if (!resp.ok) throw new Error('Konnte nichts Ähnliches finden')
const daten = await resp.json()
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufendeTreffer) return
treffer.value = daten
} catch (err) {
if (meine === laufendeTreffer) fehler.value = err.message
} finally {
if (meine === laufendeTreffer) laden.value = false
}
}
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
function passt(movie) {
const werte = props.filter.dauer
const grenze = props.filter.grenzen?.runtime
if (!werte || !grenze || movie.runtime == null) return true
if (werte[0] > grenze.min && movie.runtime < werte[0]) return false
if (werte[1] < grenze.max && movie.runtime > werte[1]) return false
return true
}
watch(
() => props.start,
(film) => {
if (!film) return
waehlen(film)
emit('gestartet')
},
{ immediate: true }
)
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
watch(
() => props.filter,
() => {
if (!gewaehlt.value) return
clearTimeout(filterTimer)
filterTimer = setTimeout(nachladen, 350)
},
{ deep: true }
)
watch(query, (term) => {
clearTimeout(timer)
timer = setTimeout(() => suchen(term), 300)
})
async function oeffnen(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="aehnlich-tab">
<!-- Die Suchleiste bleibt oben stehen: von hier geht es jederzeit
zu einem anderen Ausgangsfilm -->
<div class="suchzeile">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Von welchem Film ausgehen?"
autofocus
/>
<button
v-if="query"
type="button"
class="leeren"
title="Suche leeren"
@click="query = ''"
>
×
</button>
</div>
<!-- Tippt jemand, stehen die Vorschläge vorn, bis einer gewählt ist -->
<template v-if="query.trim().length >= 2">
<p v-if="!vorschlaege.length" class="note">Nichts gefunden.</p>
<div v-else class="grid" :class="ansicht">
<MovieCard
v-for="film in vorschlaege"
:key="film.id"
:movie="film"
:ansicht="ansicht"
@open="waehlen"
/>
</div>
</template>
<template v-else-if="gewaehlt">
<p class="label">Ähnlich wie „{{ gewaehlt.title }}“</p>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!treffer.filter(passt).length" class="note">
Nichts gefunden. Stell die Filter weiter.
</p>
<div v-if="!laden" class="grid" :class="ansicht">
<MovieCard
:movie="gewaehlt"
:ansicht="ansicht"
class="ausgangsfilm"
@open="oeffnen"
/>
<MovieCard
v-for="film in treffer.filter(passt)"
:key="film.id"
:movie="film"
:ansicht="ansicht"
@open="oeffnen"
/>
</div>
</template>
<p v-else class="note">
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
</p>
</div>
</template>
<style scoped>
.aehnlich-tab {
min-width: 0;
}
.suchzeile {
position: relative;
margin-bottom: 1.5rem;
}
.big-search {
width: 100%;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
.leeren {
position: absolute;
top: 50%;
right: 0.9rem;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
transform: translateY(-50%);
}
.leeren:hover {
color: var(--text);
}
.jahr {
color: var(--muted);
font-size: 0.85rem;
}
.note {
color: #f5c518;
font-size: 0.85rem;
}
.label {
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
/* Der Ausgangsfilm steht vorn und hebt sich ab */
.ausgangsfilm {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.aehnlich-tab > .note {
display: block;
padding: 2rem 0;
color: var(--muted);
font-size: 0.95rem;
text-align: center;
}
.aehnlich-tab > .note.error {
color: var(--accent);
}
</style>

View File

@@ -8,6 +8,10 @@ const props = defineProps({
exclude: { type: Array, required: true },
regler: { type: Array, default: () => [] },
grenzen: { type: Object, default: () => ({}) },
// Der Suche-Reiter hat ein eigenes, großes Suchfeld über der Liste
ohneSuche: Boolean,
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
mitRelevanz: Boolean,
})
const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -46,8 +50,14 @@ function beschriften(regler) {
<template>
<aside class="sidebar">
<div class="controls">
<input v-model="search" type="search" placeholder="Titel suchen…" />
<input
v-if="!ohneSuche"
v-model="search"
type="search"
placeholder="Titel suchen…"
/>
<select v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option>
<option value="rating">Beste Bewertung</option>
<option value="newest">Neueste zuerst</option>

View File

@@ -42,7 +42,7 @@ async function oeffnen(item) {
<template>
<div class="merkliste-tab">
<p v-if="!items.length" class="note">
Noch nichts gemerkt. Öffne einen Film und klicke auf „Merken“.
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
</p>
<section v-for="gruppe in gruppen" :key="gruppe.titel">

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { backdropUrl, posterUrl } from '../images'
const props = defineProps({
@@ -11,7 +11,9 @@ const props = defineProps({
gemerkt: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
const emit = defineEmits([
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
])
const vorschlagen = ref({ zustand: 'bereit' })
@@ -19,6 +21,18 @@ const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id
)
// Der eigene Zustand geht vor: nach einem Klick steht der Knopf sofort
// richtig, ohne auf die neu geladene Vorschlagsliste zu warten.
const aufDemRad = computed(() => {
if (vorschlagen.value.zustand === 'fertig') return true
if (vorschlagen.value.zustand === 'weg') return false
return schonVorgeschlagen.value
})
function radUmschalten() {
return aufDemRad.value ? zuruecknehmen() : abschicken()
}
async function merkenUmschalten() {
const weg = props.gemerkt
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
@@ -34,7 +48,7 @@ async function zuruecknehmen() {
try {
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
if (!resp.ok) throw new Error()
vorschlagen.value = { zustand: 'bereit' }
vorschlagen.value = { zustand: 'weg' }
emit('suggested')
} catch {
vorschlagen.value = { zustand: 'fehler' }
@@ -80,6 +94,28 @@ const shots = computed(() =>
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
)
// Was Leute noch geschaut haben, die diesen Film mochten
const aehnliche = ref([])
async function ladeAehnliche(id) {
aehnliche.value = []
try {
const resp = await fetch(`/api/movies/${id}/aehnliche`)
if (resp.ok && props.movie.id === id) aehnliche.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
watch(
() => props.movie.id,
(id) => {
vorschlagen.value = { zustand: 'bereit' }
ladeAehnliche(id)
},
{ immediate: true }
)
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
const grossesBild = ref(null)
@@ -107,13 +143,23 @@ function openPart(part) {
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
}
// Die Sperre muss aufs Wurzelelement: den Bildlauf macht hier <html>,
// und an <body> allein läuft die Seite hinter der Ansicht weiter mit.
onMounted(() => {
document.addEventListener('keydown', onKey)
const wurzel = document.documentElement
// Mit der Sperre verschwindet die Bildlaufleiste. Ohne Ausgleich
// rutscht die ganze Seite um deren Breite zur Seite.
const leiste = window.innerWidth - wurzel.clientWidth
wurzel.style.overflow = 'hidden'
wurzel.style.paddingRight = leiste ? `${leiste}px` : ''
document.body.style.overflow = 'hidden'
})
onUnmounted(() => {
document.removeEventListener('keydown', onKey)
document.documentElement.style.overflow = ''
document.documentElement.style.paddingRight = ''
document.body.style.overflow = ''
})
</script>
@@ -141,7 +187,7 @@ onUnmounted(() => {
</p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="spalte-links">
<div class="links">
<a
v-if="movie.on_netflix"
class="netflix"
@@ -162,31 +208,28 @@ onUnmounted(() => {
</a>
</div>
<div v-if="!host" class="vorschlag">
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
<div class="vorschlag">
<button type="button" class="merken" @click="$emit('aehnlich', movie)">
Ähnliche finden
</button>
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
<span class="ok">Steht auf dem Rad.</span>
<button
v-if="!host"
type="button"
class="merken"
:class="{ an: gemerkt }"
@click="merkenUmschalten"
>
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
</button>
<template v-if="!host">
<button
type="button"
class="merken"
:class="{ an: aufDemRad }"
:disabled="vorschlagen.zustand === 'laeuft'"
@click="zuruecknehmen"
@click="radUmschalten"
>
Wieder runternehmen
</button>
</template>
<template v-else>
<span v-if="meinVorschlag" class="hinweis">
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
Vorschlag ersetzt ihn.
</span>
<button
type="button"
:disabled="vorschlagen.zustand === 'laeuft'"
@click="abschicken"
>
Vorschlagen
{{ aufDemRad ? 'Auf dem Rad' : 'Zum Rad' }}
</button>
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
Hat nicht geklappt.
@@ -213,8 +256,11 @@ onUnmounted(() => {
</div>
</header>
<div class="links">
<section v-if="series.length">
<section v-if="movie.overview" class="handlung">
<h3>Handlung</h3>
<p class="overview">{{ movie.overview }}</p>
</section>
<section v-if="series.length" class="reihe">
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
<div class="series">
<button
@@ -238,11 +284,6 @@ onUnmounted(() => {
</div>
</section>
<section v-if="movie.overview">
<h3>Handlung</h3>
<p class="overview">{{ movie.overview }}</p>
</section>
</div>
<section v-if="trailer" class="rechts">
<div class="video">
@@ -255,6 +296,28 @@ onUnmounted(() => {
</div>
</section>
<section v-if="aehnliche.length" class="aehnliche">
<h3>Ähnliche Filme</h3>
<div class="series">
<button
v-for="film in aehnliche"
:key="film.id"
class="part"
type="button"
@click="$emit('open', film)"
>
<img
v-if="posterUrl(film.poster_path, 'w154')"
:src="posterUrl(film.poster_path, 'w154')"
:alt="film.title"
loading="lazy"
/>
<span v-else class="no-poster">Kein Cover</span>
<span class="part-title">{{ film.title }}</span>
<span class="part-year">{{ film.year }}</span>
</button>
</div>
</section>
<section v-if="shots.length" class="shots-section">
<div class="shots">
<button
@@ -274,9 +337,14 @@ onUnmounted(() => {
</section>
</article>
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
<img :src="grossesBild" :alt="movie.title" />
</div>
<!-- Muss aus der Ansicht heraus: deren backdrop-filter macht sie zum
Bezugsrahmen, und "fixed" richtet sich dann an ihr aus statt am
Fenster — das Bild landete am Anfang der Ansicht statt im Blick. -->
<Teleport to="body">
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
<img :src="grossesBild" :alt="movie.title" />
</div>
</Teleport>
</div>
</template>
@@ -288,6 +356,9 @@ onUnmounted(() => {
display: flex;
justify-content: center;
overflow-y: auto;
/* Am unteren Ende soll der Bildlauf stehen bleiben, nicht auf die
Seite dahinter überspringen */
overscroll-behavior: contain;
padding: 2rem 1rem;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
@@ -307,17 +378,22 @@ onUnmounted(() => {
border-radius: 14px;
}
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
.sheet > header,
.sheet > .spalte-links {
grid-column: 1;
/* Oben links Cover, Text und Knöpfe, rechts daneben der Trailer. Darunter
über die ganze Breite: Handlung, Reihe, Ähnliche, Bilder. */
.sheet > header {
grid-area: 1 / 1;
}
.sheet > .rechts {
grid-column: 2;
grid-row: 1 / span 2;
grid-area: 1 / 2 / span 2;
}
.sheet > .handlung {
grid-area: 2 / 1;
}
.sheet > .reihe,
.sheet > .aehnliche,
.sheet > .shots-section {
grid-column: 1 / -1;
}
@@ -327,10 +403,12 @@ onUnmounted(() => {
grid-template-columns: 1fr;
}
.sheet > .rechts,
.sheet > .shots-section,
.sheet > header,
.sheet > .spalte-links {
.sheet > .rechts,
.sheet > .handlung,
.sheet > .reihe,
.sheet > .aehnliche,
.sheet > .shots-section {
grid-column: 1;
grid-row: auto;
}
@@ -403,7 +481,7 @@ h2 {
.links {
display: flex;
gap: 0.5rem;
margin-top: 0.35rem;
margin-top: 0.75rem;
}
.netflix,
@@ -424,10 +502,13 @@ h2 {
background: #f5c518;
}
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
Trennstrich nur so breit wie die Knöpfe darunter */
.vorschlag {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
gap: 0.5rem;
align-self: stretch;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
@@ -473,7 +554,8 @@ h2 {
.gesehen {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
gap: 0.5rem;
align-self: stretch;
align-items: center;
margin-top: 0.6rem;
}

View File

@@ -1,6 +1,13 @@
<script setup>
import { ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
const props = defineProps({
// Was die Filterleiste eingestellt hat
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
})
const emit = defineEmits(['open'])
@@ -8,28 +15,50 @@ const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref('')
const searched = ref(false)
const gestoebert = ref(false)
let timer = null
let laufende = 0
async function run(term) {
if (term.trim().length < 2) {
results.value = []
searched.value = false
return
// Ohne Suchbegriff stöbert TMDB nach den Filtern, mit Begriff sucht es
// nach Titeln — dann greifen die Filter erst hier.
function passt(movie) {
const f = props.filter
const g = f.grenzen || {}
const drin = (werte, wert, key) => {
if (!werte || wert == null) return true
const grenze = g[key]
if (!grenze) return true
if (werte[0] > grenze.min && wert < werte[0]) return false
if (werte[1] < grenze.max && wert > werte[1]) return false
return true
}
const genres = movie.genres || []
if ((f.include || []).length && !genres.some((x) => f.include.includes(x))) return false
if ((f.exclude || []).some((x) => genres.includes(x))) return false
return (
drin(f.stimmen, movie.vote_count, 'vote_count') &&
drin(f.jahr, movie.year, 'year') &&
drin(f.note, movie.vote_average, 'vote_average')
)
}
async function run(term) {
const meine = ++laufende
loading.value = true
error.value = ''
const stoebern = term.trim().length < 2
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
const url = stoebern
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
: `/api/search?q=${encodeURIComponent(term.trim())}`
const resp = await fetch(url)
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
results.value = stoebern ? data : data.filter(passt)
gestoebert.value = stoebern
} catch (err) {
if (meine === laufende) error.value = err.message
} finally {
@@ -37,10 +66,24 @@ async function run(term) {
}
}
watch(query, (term) => {
function spaeterSuchen() {
clearTimeout(timer)
timer = setTimeout(() => run(term), 350)
})
timer = setTimeout(() => run(query.value), 350)
}
// Enter sucht sofort, statt die Tippbremse abzuwarten
function jetztSuchen() {
clearTimeout(timer)
run(query.value)
}
function leeren() {
query.value = ''
jetztSuchen()
}
watch(query, spaeterSuchen)
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
@@ -50,24 +93,35 @@ async function open(movie) {
<template>
<div class="search-tab">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Globale Suche …"
autofocus
/>
<div class="suchzeile">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Globale Suche …"
autofocus
@keyup.enter="jetztSuchen"
/>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
×
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
Suchen
</button>
</div>
<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 v-else-if="!results.length" class="note">
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
</p>
<div class="grid">
<div class="grid" :class="ansicht">
<MovieCard
v-for="movie in results"
:key="movie.id"
:movie="movie"
:ansicht="ansicht"
@open="open"
/>
</div>
@@ -75,19 +129,63 @@ async function open(movie) {
</template>
<style scoped>
.big-search {
width: 100%;
.search-tab {
min-width: 0;
}
.suchzeile {
position: relative;
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 0.9rem 1.1rem;
}
.big-search {
flex: 1;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
.leeren {
position: absolute;
top: 50%;
right: 7.8rem;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
transform: translateY(-50%);
}
.leeren:hover {
color: var(--text);
}
.neu {
padding: 0 1.4rem;
font-size: 0.95rem;
font-weight: 600;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.neu:hover {
border-color: var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.note {
padding: 2.5rem 0;
color: var(--muted);

24
frontend/src/filter.js Normal file
View File

@@ -0,0 +1,24 @@
// Die Filterleiste steht auf Anschlag, solange niemand sie anfasst. Genau
// dann darf sie auch nichts wegschneiden — also wandert nur ins Backend,
// was tatsächlich verstellt wurde.
function spanne(filter, name, schluessel, ziel) {
const werte = filter[name]
const grenze = filter.grenzen?.[schluessel]
if (!werte || !grenze) return {}
const raus = {}
if (werte[0] > grenze.min) raus[`${ziel}_min`] = werte[0]
if (werte[1] < grenze.max) raus[`${ziel}_max`] = werte[1]
return raus
}
export function filterParameter(filter) {
return {
sort: filter.sort || 'rating',
include: (filter.include || []).join(','),
exclude: (filter.exclude || []).join(','),
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
...spanne(filter, 'dauer', 'runtime', 'dauer'),
...spanne(filter, 'jahr', 'year', 'jahr'),
...spanne(filter, 'note', 'vote_average', 'note'),
}
}