This commit is contained in:
team3
2026-09-18 23:47:25 +02:00
parent db77c1f985
commit a90044b0a7
21 changed files with 1111 additions and 253 deletions

View File

@@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import KiTab from './components/KiTab.vue'
import PersonenTab from './components/PersonenTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
@@ -24,11 +25,20 @@ import { useLangeListe } from './composables/useLangeListe'
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
const {
runde, ich, michSetzen,
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
meinVorschlag, aufDemRad, ladeVorschlag,
merkliste, ladeMerkliste,
wuensche, ladeWuensche,
runde,
ich,
michSetzen,
gesehen,
gesehenIds,
gesehenZahl,
ladeGesehen,
meinVorschlag,
aufDemRad,
ladeVorschlag,
merkliste,
ladeMerkliste,
wuensche,
ladeWuensche,
} = useMeins({ host })
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
@@ -37,44 +47,74 @@ const {
const angemeldet = computed(() => !!ich.value)
const {
movies, loading, error, load,
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
movies,
loading,
error,
load,
netflixMovies,
canonMovies,
canonGroups,
netflixEntries,
SORTS,
} = useKatalog()
const {
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
search,
sort,
suchSort,
suchAnzahl,
include,
exclude,
grenzen,
ranges,
genreOptions,
toggleGenre,
toggleAll,
resetGenres,
nichtGesehen,
passt,
suchFilter,
} = useFilter({
activeTab,
quelle,
suchArt,
netflixMovies,
canonMovies,
gesehenIds,
ohneGesehene,
})
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil
function filterGruppen(entries, term) {
return entries
.filter(
(e) =>
e.parts.some((m) => passt(m)) &&
(!term || e.parts.some((m) => matches(m, term)))
(e) => e.parts.some((m) => passt(m)) && (!term || e.parts.some((m) => matches(m, term))),
)
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
}
const visible = computed(() =>
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase())
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase()),
)
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
const visibleCanon = computed(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
filterGruppen(canonGroups.value, search.value.trim().toLowerCase()),
)
const aktuelleListe = computed(() =>
quelle.value === 'netflix' ? visible.value : visibleCanon.value
quelle.value === 'netflix' ? visible.value : visibleCanon.value,
)
// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste
const quellen = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{
id: 'netflix',
label: 'Netflix',
count: netflixMovies.value.filter(nichtGesehen).length,
},
{
id: 'canon',
label: 'Horror-Kanon',
@@ -98,7 +138,11 @@ const tabs = computed(() => [
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
{
id: 'wuensche',
label: 'Wünsche',
count: wuensche.value.filter((w) => !w.done_at).length,
},
...(host.value || angemeldet.value
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
: []),
@@ -107,9 +151,7 @@ const tabs = computed(() => [
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
// legt sie sich über einen Knopf über die Seite
const filterOffen = ref(false)
const mitFilter = computed(() =>
['katalog', 'suche', 'merkliste'].includes(activeTab.value)
)
const mitFilter = computed(() => ['katalog', 'suche', 'merkliste'].includes(activeTab.value))
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
// dafür hat er eine eigene Leiste
@@ -124,9 +166,7 @@ const leuteMitSammlung = computed(() => {
if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine })
nach.get(name).anzahl += 1
}
return [...nach.values()].sort(
(a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de')
)
return [...nach.values()].sort((a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de'))
})
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
@@ -180,9 +220,7 @@ const series = computed(() => {
: []
if (group.length < 2) return []
return [...group].sort((a, b) =>
(a.release_date || '').localeCompare(b.release_date || '')
)
return [...group].sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''))
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
@@ -195,10 +233,21 @@ function zurAehnlichkeitsuche(movie) {
suchArt.value = 'aehnlich'
}
// Aus der Filmansicht zu einer Person: Regie oder Besetzung
const personStart = ref(null)
function zurPersonensuche(person) {
personStart.value = person
selected.value = null
activeTab.value = 'suche'
suchArt.value = 'personen'
}
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
const SUCHARTEN = [
{ id: 'search', label: 'Titel' },
{ id: 'aehnlich', label: 'Ähnliche Filme' },
{ id: 'personen', label: 'Personen' },
{ id: 'ki', label: 'KI' },
]
@@ -238,12 +287,7 @@ onMounted(load)
Filter
</button>
<span v-if="host" class="host">Host</span>
<NamensWahl
:items="runde"
:mine="ich"
:host="host"
:anmelden="michSetzen"
/>
<NamensWahl :items="runde" :mine="ich" :host="host" :anmelden="michSetzen" />
</div>
</header>
@@ -280,6 +324,17 @@ onMounted(load)
@gestartet="aehnlichStart = null"
/>
<PersonenTab
v-else-if="suchArt === 'personen'"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
:start="personStart"
@open="selected = $event"
@gestartet="personStart = null"
/>
<KiTab
v-else
:angemeldet="angemeldet"
@@ -294,6 +349,8 @@ onMounted(load)
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
<FilterSidebar
v-model:sort="suchSort"
v-model:anzahl="suchAnzahl"
:mit-anzahl="suchArt !== 'personen'"
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
@@ -363,12 +420,7 @@ onMounted(load)
@changed="ladeWuensche"
/>
<VerwaltungTab
v-else-if="activeTab === 'verwaltung'"
:items="runde"
:host="host"
:ich="ich"
/>
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
<StreamTab v-else-if="activeTab === 'stream'" />
@@ -459,6 +511,7 @@ onMounted(load)
@suggested="nachVorschlag"
@watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
@person="zurPersonensuche"
/>
</div>
</template>
@@ -649,10 +702,7 @@ onMounted(load)
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
oder die halbe Reihe, falls die schmaler ist. */
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
.note {

View File

@@ -3,9 +3,6 @@ import { computed, 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
// Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel.
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
const VORSCHLAEGE = { kompakt: 20, details: 8 }
@@ -24,9 +21,7 @@ const emit = defineEmits(['open', 'gestartet'])
const query = ref('')
const gefunden = ref([])
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
const vorschlaege = computed(() =>
gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8)
)
const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8))
const gewaehlt = ref(null)
const treffer = ref([])
const laden = ref(false)
@@ -86,8 +81,8 @@ async function nachladen() {
laden.value = true
fehler.value = ''
try {
// Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit'
const params = new URLSearchParams({
limit: TREFFER,
voll: 'true',
...filterParameter(props.filter),
})
@@ -123,7 +118,7 @@ watch(
waehlen(film)
emit('gestartet')
},
{ immediate: true }
{ immediate: true },
)
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
@@ -134,7 +129,7 @@ watch(
clearTimeout(filterTimer)
filterTimer = setTimeout(nachladen, 350)
},
{ deep: true }
{ deep: true },
)
watch(query, (term) => {
@@ -160,13 +155,7 @@ async function oeffnen(movie) {
placeholder="Von welchem Film ausgehen?"
autofocus
/>
<button
v-if="query"
type="button"
class="leeren"
title="Suche leeren"
@click="query = ''"
>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
@@ -196,12 +185,7 @@ async function oeffnen(movie) {
</p>
<div v-if="!laden" class="grid" :class="ansicht">
<MovieCard
:movie="gewaehlt"
:ansicht="ansicht"
class="ausgangsfilm"
@open="oeffnen"
/>
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
<MovieCard
v-for="film in treffer.filter(passt)"
:key="film.id"
@@ -277,10 +261,7 @@ async function oeffnen(movie) {
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
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 */

View File

@@ -16,6 +16,8 @@ const props = defineProps({
ohneSort: Boolean,
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
quellen: { type: Array, default: () => [] },
// Die Suchen lassen wählen, wie viele Treffer es sein sollen
mitAnzahl: Boolean,
})
const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -23,9 +25,13 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
const search = defineModel('search', { type: String, default: '' })
const sort = defineModel('sort', { type: String, default: 'random' })
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true })
const ohneGesehene = defineModel('ohneGesehene', {
type: Boolean,
default: true,
})
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
const quelle = defineModel('quelle', { type: String, default: '' })
const anzahl = defineModel('anzahl', { type: Number, default: 24 })
// Auch ein verschobener Regler zählt als aktiver Filter
const etwasAktiv = computed(
@@ -36,7 +42,7 @@ const etwasAktiv = computed(
const spanne = ranges.value[r.key]
const grenze = props.grenzen[r.key]
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
})
}),
)
function beschriften(regler) {
@@ -50,7 +56,6 @@ function beschriften(regler) {
return zahl + (regler.einheit || '')
}
}
</script>
<template>
@@ -69,12 +74,7 @@ function beschriften(regler) {
</button>
</div>
<input
v-if="!ohneSuche"
v-model="search"
type="search"
placeholder="Titel suchen…"
/>
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
<select v-if="!ohneSort" v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option>
@@ -84,19 +84,16 @@ function beschriften(regler) {
<option value="title">Titel A–Z</option>
</select>
<label v-if="mitAnzahl" class="anzahl">
<span>Ergebnisse <b>{{ anzahl }}</b></span>
<input v-model.number="anzahl" type="range" min="1" max="100" />
</label>
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
Details
</button>
</div>
@@ -309,6 +306,23 @@ h4 {
text-decoration: line-through;
}
.anzahl {
display: flex;
flex-direction: column;
gap: 0.2rem;
color: var(--muted);
font-size: 0.82rem;
}
.anzahl b {
color: var(--text);
}
.controls .anzahl input {
padding: 0;
accent-color: var(--accent);
}
.ansicht {
display: flex;
gap: 0.3rem;

View File

@@ -28,14 +28,14 @@ const gefragt = ref(false)
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
const leisteVerstellt = computed(() => {
const { sort, ...rest } = filterParameter(props.filter)
const { sort, limit, ...rest } = filterParameter(props.filter)
return Object.values(rest).some((w) => w !== '' && w != null)
})
const kannFragen = computed(
() =>
!laden.value &&
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value)
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
)
async function fragen() {
@@ -196,10 +196,7 @@ textarea:focus {
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
.note {

View File

@@ -14,7 +14,7 @@ const props = defineProps({
})
const emit = defineEmits([
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
])
const vorschlagen = ref({ zustand: 'bereit' })
@@ -109,11 +109,25 @@ async function ladeAehnliche(id) {
}
}
// Regie und Besetzung — jeder Name führt zur Personensuche
const leute = ref({ regie: [], cast: [] })
async function ladeLeute(id) {
leute.value = { regie: [], cast: [] }
try {
const resp = await fetch(`/api/movies/${id}/credits`)
if (resp.ok && props.movie.id === id) leute.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
watch(
() => props.movie.id,
(id) => {
vorschlagen.value = { zustand: 'bereit' }
ladeAehnliche(id)
ladeLeute(id)
},
{ immediate: true }
)
@@ -176,7 +190,13 @@ onUnmounted(() => {
<header>
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
<div class="head-text">
<h2>{{ movie.title }}</h2>
<h2>
{{ movie.title }}
<!-- Läuft nur im Abo: dann bleibt fürs Leihen nichts übrig -->
<span v-if="movie.rentable != null && !movie.rentable" class="nicht-leihbar">
Nicht leihbar
</span>
</h2>
<p v-if="movie.original_title !== movie.title" class="original">
{{ movie.original_title }}
</p>
@@ -188,6 +208,22 @@ onUnmounted(() => {
</span>
</p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-for="[titel, liste] in [['Regie', leute.regie], ['Mit', leute.cast]]" :key="titel">
<template v-if="liste.length">
<span class="leute-titel">{{ titel }}</span>
<button
v-for="p in liste"
:key="p.id"
type="button"
class="person"
:title="`Filme mit ${p.name}`"
@click="$emit('person', p)"
>
{{ p.name }}
</button>
</template>
</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="links">
<a
@@ -208,6 +244,16 @@ onUnmounted(() => {
>
IMDb
</a>
<!-- TMDBs Anbieterliste für Deutschland: wo man leihen, kaufen
oder streamen kann. IMDb zeigt davon nur Amazon. -->
<a
class="tmdb"
:href="`https://www.themoviedb.org/movie/${movie.id}/watch?locale=DE`"
target="_blank"
rel="noopener"
>
TMDB
</a>
</div>
<div class="vorschlag">
@@ -244,12 +290,8 @@ onUnmounted(() => {
</p>
</div>
<div v-if="host || gesehen" class="gesehen">
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
Steht unter „Gesehen“.
</span>
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
<button
v-else-if="host"
type="button"
:disabled="merken.zustand === 'laeuft'"
@click="alsGesehen"
@@ -485,6 +527,26 @@ h2 {
font-size: 0.82rem;
}
.leute-titel {
margin-right: 0.35rem;
color: var(--muted);
font-size: 0.78rem;
}
/* Namen als kleine Pillen, ein Klick führt zur Personensuche */
.person {
margin: 0 0.3rem 0.3rem 0;
padding: 0.15rem 0.55rem;
color: var(--text);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 20px;
}
.person:hover {
border-color: var(--accent);
}
.teaser {
color: #cfcfda;
font-size: 0.98rem;
@@ -498,7 +560,8 @@ h2 {
}
.netflix,
.imdb {
.imdb,
.tmdb {
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
@@ -510,11 +573,29 @@ h2 {
background: var(--accent);
}
.tmdb {
color: #0d253f;
background: #01b4e4;
}
.imdb {
color: #111;
background: #f5c518;
}
.nicht-leihbar {
display: inline-block;
margin-left: 0.5rem;
padding: 0.15rem 0.45rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
vertical-align: middle;
background: var(--accent);
border-radius: 4px;
}
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
Trennstrich nur so breit wie die Knöpfe darunter */
.vorschlag {
@@ -609,7 +690,8 @@ h2 {
}
.netflix:hover,
.imdb:hover {
.imdb:hover,
.tmdb:hover {
filter: brightness(1.15);
}
@@ -749,7 +831,8 @@ h3 {
.gesehen button,
.merken,
.netflix,
.imdb {
.imdb,
.tmdb {
padding: 0.65rem 1rem;
}

View File

@@ -0,0 +1,360 @@
<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>

View File

@@ -58,7 +58,7 @@ async function run(term) {
try {
const url = stoebern
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
: `/api/search?q=${encodeURIComponent(term.trim())}`
: `/api/search?q=${encodeURIComponent(term.trim())}&limit=${props.filter.anzahl || 24}`
const resp = await fetch(url)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const data = await resp.json()
@@ -113,15 +113,15 @@ async function open(movie) {
<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>
<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}“.` }}
{{
gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.`
}}
</p>
<div class="grid" :class="ansicht">
@@ -195,10 +195,7 @@ async function open(movie) {
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
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 */

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { computed, reactive, ref } from 'vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -7,111 +7,111 @@ const props = defineProps({
host: Boolean,
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
angemeldet: Boolean,
});
})
const emit = defineEmits(["changed"]);
const emit = defineEmits(['changed'])
const entwurf = ref("");
const entwurf = ref('')
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
const bearbeitet = ref(null);
const bearbeitung = ref("");
const bearbeitet = ref(null)
const bearbeitung = ref('')
function darfAendern(item) {
return props.host || item.mine;
return props.host || item.mine
}
function bearbeiten(item) {
bearbeitet.value = item.id;
bearbeitung.value = item.text;
bearbeitet.value = item.id
bearbeitung.value = item.text
}
async function speichern() {
const text = bearbeitung.value.trim();
const text = bearbeitung.value.trim()
if (text) {
await fetch(`/api/features/${bearbeitet.value}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
emit("changed");
})
emit('changed')
}
bearbeitet.value = null;
bearbeitet.value = null
}
// Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
const offene = computed(() => props.items.filter((w) => !w.done_at));
const erledigte = computed(() => props.items.filter((w) => w.done_at));
const offene = computed(() => props.items.filter((w) => !w.done_at))
const erledigte = computed(() => props.items.filter((w) => w.done_at))
async function anlegen() {
if (!entwurf.value.trim()) return;
await fetch("/api/features", {
method: "POST",
headers: { "Content-Type": "application/json" },
if (!entwurf.value.trim()) return
await fetch('/api/features', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: entwurf.value }),
});
entwurf.value = "";
emit("changed");
})
entwurf.value = ''
emit('changed')
}
async function loeschen(item) {
await fetch(`/api/features/${item.id}`, { method: "DELETE" });
emit("changed");
await fetch(`/api/features/${item.id}`, { method: 'DELETE' })
emit('changed')
}
async function abstimmen(item) {
await fetch(`/api/features/${item.id}/vote`, { method: "POST" });
emit("changed");
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' })
emit('changed')
}
async function erledigen(item, done) {
await fetch(`/api/features/${item.id}/done`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done }),
});
emit("changed");
})
emit('changed')
}
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
const aufgeklappt = reactive(new Set());
const notizEntwurf = reactive({});
const aufgeklappt = reactive(new Set())
const notizEntwurf = reactive({})
function umschalten(id) {
if (aufgeklappt.has(id)) aufgeklappt.delete(id);
else aufgeklappt.add(id);
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
else aufgeklappt.add(id)
}
function ergaenzungsLabel(item) {
const n = item.notes.length;
if (!n) return "Ergänzen";
return n === 1 ? "1 Ergänzung" : `${n} Ergänzungen`;
const n = item.notes.length
if (!n) return 'Ergänzen'
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen`
}
async function anmerken(item) {
const text = (notizEntwurf[item.id] || "").trim();
if (!text) return;
const text = (notizEntwurf[item.id] || '').trim()
if (!text) return
await fetch(`/api/features/${item.id}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
notizEntwurf[item.id] = "";
emit("changed");
})
notizEntwurf[item.id] = ''
emit('changed')
}
async function notizLoeschen(notiz) {
await fetch(`/api/feature-notes/${notiz.id}`, { method: "DELETE" });
emit("changed");
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' })
emit('changed')
}
function datum(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
@@ -129,9 +129,7 @@ function datum(iso) {
placeholder="Was fehlt dir auf der Seite?"
@keydown.ctrl.enter="anlegen"
></textarea>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
Eintragen
</button>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">Eintragen</button>
</div>
<section
@@ -172,14 +170,8 @@ function datum(iso) {
@keydown.esc="bearbeitet = null"
></textarea>
<div class="zeile">
<button type="button" class="klein" @click="speichern">
Speichern
</button>
<button
type="button"
class="klein"
@click="bearbeitet = null"
>
<button type="button" class="klein" @click="speichern">Speichern</button>
<button type="button" class="klein" @click="bearbeitet = null">
Abbrechen
</button>
</div>
@@ -188,9 +180,7 @@ function datum(iso) {
<template v-else>
<p class="text">{{ w.text }}</p>
<p class="meta">
<span :class="{ eigen: w.mine }">{{
w.name || "Anonym"
}}</span>
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span>
<span class="punkt">·</span>{{ datum(w.created_at) }}
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt
@@ -210,9 +200,7 @@ function datum(iso) {
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{
n.name || "Anonym"
}}</span>
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"
@@ -250,12 +238,10 @@ function datum(iso) {
v-if="host"
type="button"
class="weg"
:title="
w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
"
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
@click="erledigen(w, !w.done_at)"
>
{{ w.done_at ? "↺" : "✓" }}
{{ w.done_at ? '↺' : '✓' }}
</button>
<button
v-if="darfAendern(w) && bearbeitet !== w.id"
@@ -288,8 +274,8 @@ function datum(iso) {
.spalten {
display: grid;
grid-template-areas:
"neu erledigt"
"offen erledigt";
'neu erledigt'
'offen erledigt';
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
gap: 0 2.5rem;
@@ -558,9 +544,9 @@ h3 {
@media (max-width: 900px) {
.spalten {
grid-template-areas:
"neu"
"offen"
"erledigt";
'neu'
'offen'
'erledigt';
grid-template-columns: 1fr;
}
}

View File

@@ -35,7 +35,7 @@ const TAB_IDS = [
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
const QUELLEN = ['netflix', 'canon']
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
const SUCHARTEN = ['search', 'aehnlich', 'ki']
const SUCHARTEN = ['search', 'aehnlich', 'personen', 'ki']
function gemerkt(schluessel, standard) {
const wert = ref(localStorage.getItem(schluessel) ?? standard)

View File

@@ -5,16 +5,34 @@ import { computed, ref, watch } from 'vue'
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
export const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{
key: 'vote_count',
label: 'Bewertungen',
feld: 'vote_count',
step: 1,
gestaucht: true,
},
{
key: 'runtime',
label: 'Dauer',
feld: 'runtime',
step: 1,
einheit: ' Min.',
},
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
{
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 },
vote_count: { min: 0, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
@@ -22,21 +40,62 @@ const SUCH_GRENZEN = {
// 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',
'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 }))
export function useFilter({
activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
activeTab,
quelle,
suchArt,
netflixMovies,
canonMovies,
gesehenIds,
ohneGesehene,
}) {
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')
// Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
// von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
// zuerst. Die Wahl bleibt je Suche erhalten.
const suchSorts = ref({
search: 'relevanz',
aehnlich: 'random',
personen: 'newest',
})
const suchSort = computed({
get: () => suchSorts.value[suchArt.value] ?? 'relevanz',
set: (wert) => {
suchSorts.value[suchArt.value] = wert
},
})
// Wie viele Treffer eine Suche bringen soll — je Suche gemerkt. Bei
// Personen gibt es das nicht: da sind es eben alle Filme.
const suchAnzahlen = ref({ search: 24, aehnlich: 20, ki: 12 })
const suchAnzahl = computed({
get: () => suchAnzahlen.value[suchArt.value] ?? 24,
set: (wert) => {
suchAnzahlen.value[suchArt.value] = wert
},
})
const include = ref([])
const exclude = ref([])
@@ -47,7 +106,9 @@ export function useFilter({
if (suchReiter()) return SUCH_GRENZEN
const out = {}
for (const regler of REGLER) {
const werte = pool().map((m) => m[regler.feld]).filter((v) => v != null)
const werte = pool()
.map((m) => m[regler.feld])
.filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
@@ -60,9 +121,17 @@ export function useFilter({
const ranges = ref({})
// Im Suche-Reiter starten zwei Regler nicht am Anschlag: unter 60
// Minuten sind es Kurzfilme, unter 50 Stimmen Making-ofs und Cameos.
// Wer das sehen will, zieht den Regler runter — bewusst.
const SUCH_START = { runtime: 60, vote_count: 50 }
function resetRanges() {
ranges.value = Object.fromEntries(
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
Object.entries(grenzen.value).map(([k, v]) => [
k,
[suchReiter() && SUCH_START[k] != null ? SUCH_START[k] : v.min, v.max],
]),
)
}
@@ -152,6 +221,7 @@ export function useFilter({
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
anzahl: suchAnzahl.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
@@ -166,6 +236,7 @@ export function useFilter({
search,
sort,
suchSort,
suchAnzahl,
include,
exclude,
grenzen,

View File

@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
return raus
}
// Die Filter der Leiste auf einen Treffer anwenden, den TMDB schon
// geliefert hat — ein Regler auf Anschlag schneidet nichts weg
export function passtZurLeiste(movie, filter) {
const g = filter.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 ((filter.include || []).length && !genres.some((x) => filter.include.includes(x))) return false
if ((filter.exclude || []).some((x) => genres.includes(x))) return false
return (
drin(filter.stimmen, movie.vote_count, 'vote_count') &&
drin(filter.dauer, movie.runtime, 'runtime') &&
drin(filter.jahr, movie.year, 'year') &&
drin(filter.note, movie.vote_average, 'vote_average')
)
}
export function filterParameter(filter) {
return {
sort: filter.sort || 'rating',
...(filter.anzahl ? { limit: filter.anzahl } : {}),
include: (filter.include || []).join(','),
exclude: (filter.exclude || []).join(','),
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),