Files
horror/frontend/src/components/PersonenTab.vue
2026-09-18 23:47:25 +02:00

361 lines
8.1 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>
// Nach Leuten suchen: einen Namen tippen, eine Person wählen, dann
// stehen ihre Filme da — getrennt nach gespielt und Regie geführt.
import { computed, ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
import { posterUrl } from '../images'
import { passtZurLeiste } from '../filter.js'
const props = defineProps({
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
// Kommt aus der Filmansicht: mit dieser Person soll es losgehen
start: { type: Object, default: null },
})
const emit = defineEmits(['open', 'gestartet'])
const query = ref('')
const personen = ref([])
const personenLaden = ref(false)
const gewaehlt = ref(null)
const rollen = ref({ gespielt: [], regie: [] })
const laden = ref(false)
const fehler = ref('')
let timer = null
let laufende = 0
let laufendeWahl = 0
async function suchen(term) {
if (term.trim().length < 2) {
personen.value = []
return
}
const meine = ++laufende
personenLaden.value = true
try {
const resp = await fetch(`/api/personen?q=${encodeURIComponent(term.trim())}`)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const daten = await resp.json()
if (meine !== laufende) return
personen.value = daten
} catch {
if (meine === laufende) personen.value = []
} finally {
if (meine === laufende) personenLaden.value = false
}
}
watch(query, () => {
clearTimeout(timer)
timer = setTimeout(() => suchen(query.value), 350)
})
async function waehlen(person) {
const meine = ++laufendeWahl
gewaehlt.value = person
query.value = ''
personen.value = []
laden.value = true
fehler.value = ''
try {
const resp = await fetch(`/api/personen/${person.id}/filme`)
if (!resp.ok) throw new Error('Das hat nicht geklappt.')
const daten = await resp.json()
if (meine !== laufendeWahl) return
rollen.value = daten
} catch (err) {
if (meine === laufendeWahl) fehler.value = err.message
} finally {
if (meine === laufendeWahl) laden.value = false
}
}
function passt(movie) {
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
return passtZurLeiste(movie, props.filter)
}
// Die Reihenfolge aus der Leiste. "Wie auf IMDb" ist hier die von
// TMDB: die bekanntesten Filme zuerst.
const wuerfel = new Map()
function zufall(movie) {
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
return wuerfel.get(movie.id)
}
const SORTS = {
random: (a, b) => zufall(a) - zufall(b),
rating: (a, b) => (b.vote_average || 0) - (a.vote_average || 0),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function sortiert(liste) {
const sort = SORTS[props.filter.sort]
return sort ? [...liste].sort(sort) : liste
}
const abschnitte = computed(() =>
[
['Als Schauspieler', rollen.value.gespielt],
['Als Regisseur', rollen.value.regie],
]
.map(([titel, liste]) => [titel, sortiert(liste.filter(passt))])
.filter(([, liste]) => liste.length),
)
watch(
() => props.start,
(person) => {
if (!person) return
waehlen(person)
emit('gestartet')
},
{ immediate: true }
)
async function oeffnen(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="personen-tab">
<div class="suchzeile">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Wen suchst du?"
autofocus
/>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
<!-- Tippt jemand, stehen die Leute vorn, bis einer gewählt ist -->
<template v-if="query.trim().length >= 2">
<p v-if="!personen.length && !personenLaden" class="note">Nichts gefunden.</p>
<ul v-else class="leute">
<li v-for="p in personen" :key="p.id">
<button type="button" @click="waehlen(p)">
<img v-if="p.profile_path" :src="posterUrl(p.profile_path, 'w185')" :alt="p.name" />
<span v-else class="kein-bild"></span>
<span class="text">
<strong>{{ p.name }}</strong>
<span class="fach">{{ p.fach }}</span>
<span v-if="p.bekannt_fuer.length" class="bekannt">
{{ p.bekannt_fuer.join(' · ') }}
</span>
</span>
</button>
</li>
</ul>
</template>
<template v-else-if="gewaehlt">
<div class="kopf">
<img
v-if="gewaehlt.profile_path"
:src="posterUrl(gewaehlt.profile_path, 'w185')"
:alt="gewaehlt.name"
/>
<div>
<h3>{{ gewaehlt.name }}</h3>
<p class="fach">{{ gewaehlt.fach }}</p>
</div>
</div>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!abschnitte.length" class="note">Nichts gefunden. Stell die Filter weiter.</p>
<section v-for="[titel, liste] in abschnitte" :key="titel">
<p class="label">
{{ titel }}
<span class="anzahl">{{ liste.length }}</span>
</p>
<div class="grid" :class="ansicht">
<MovieCard
v-for="film in liste"
:key="film.id"
:movie="film"
:ansicht="ansicht"
:gesehen="gesehenIds.has(film.id)"
@open="oeffnen"
/>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.personen-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);
}
/* Die Trefferliste: Foto, Name, Fach und wofür man die Person kennt */
.leute {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.7rem;
list-style: none;
}
.leute button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.6rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 10px;
}
.leute button:hover {
border-color: var(--accent);
}
.leute img,
.kein-bild {
flex-shrink: 0;
width: 56px;
height: 84px;
object-fit: cover;
background: #1c1c22;
border-radius: 6px;
}
.text {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.fach,
.bekannt {
color: var(--muted);
font-size: 0.8rem;
}
.bekannt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kopf {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
}
.kopf img {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: 8px;
}
.kopf h3 {
font-size: 1.3rem;
}
section {
margin-bottom: 2rem;
}
.label {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.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: 2rem 0;
color: var(--muted);
font-size: 0.95rem;
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
.leute {
grid-template-columns: 1fr;
}
}
</style>