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

@@ -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);