226 lines
5.4 KiB
Vue
226 lines
5.4 KiB
Vue
<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>
|