Files
horror/frontend/src/components/SearchTab.vue
2026-09-16 17:55:23 +02:00

226 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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' },
gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
})
const emit = defineEmits(['open'])
const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref('')
const gestoebert = ref(false)
let timer = null
let laufende = 0
// 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')
)
}
// Schon gelaufene Filme fallen weg, solange der Schalter in der Leiste steht
function nichtGesehen(movie) {
return !props.ohneGesehene || !props.gesehenIds.has(movie.id)
}
async function run(term) {
const meine = ++laufende
loading.value = true
error.value = ''
const stoebern = term.trim().length < 2
try {
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 = (stoebern ? data : data.filter(passt)).filter(nichtGesehen)
gestoebert.value = stoebern
} catch (err) {
if (meine === laufende) error.value = err.message
} finally {
if (meine === laufende) loading.value = false
}
}
function spaeterSuchen() {
clearTimeout(timer)
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 })
watch(() => props.ohneGesehene, spaeterSuchen)
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">
<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="!results.length" class="note">
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
</p>
<div class="grid" :class="ansicht">
<MovieCard
v-for="movie in results"
:key="movie.id"
:movie="movie"
:ansicht="ansicht"
:gesehen="gesehenIds.has(movie.id)"
@open="open"
/>
</div>
</div>
</template>
<style scoped>
.search-tab {
min-width: 0;
}
.suchzeile {
position: relative;
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.big-search {
flex: 1;
/* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus
dem Bild, wenn der Schirm schmal ist */
min-width: 0;
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 {
flex-shrink: 0;
padding: 0 1.4rem;
/* Der Leeren-Knopf im Feld richtet sich nach dieser Breite */
width: 7.2rem;
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))
);
}
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>