update
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { abonnieren } from './live'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import AehnlichTab from './components/AehnlichTab.vue'
|
||||
import MerklisteTab from './components/MerklisteTab.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import NamensWahl from './components/NamensWahl.vue'
|
||||
@@ -20,6 +21,10 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
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')
|
||||
|
||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
||||
@@ -27,11 +32,29 @@ const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
||||
|
||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
||||
const selected = ref(null)
|
||||
|
||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
||||
// Bilder kommen gleich danach.
|
||||
//
|
||||
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
||||
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
||||
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
||||
async function filmOeffnen(movie) {
|
||||
selected.value = movie
|
||||
if (!movie?.id) return
|
||||
try {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben mit den Listendaten */
|
||||
}
|
||||
}
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = [
|
||||
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche',
|
||||
'verwaltung', 'stream',
|
||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||
'wuensche', 'verwaltung', 'stream',
|
||||
]
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||
@@ -54,7 +77,25 @@ const REGLER = [
|
||||
{ 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 },
|
||||
runtime: { min: 0, max: 240 },
|
||||
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||
vote_average: { min: 0, max: 10 },
|
||||
}
|
||||
|
||||
// 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',
|
||||
'Western',
|
||||
].map((genre) => ({ genre, count: null }))
|
||||
|
||||
const grenzen = computed(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const out = {}
|
||||
for (const regler of REGLER) {
|
||||
@@ -135,6 +176,7 @@ function resetGenres() {
|
||||
|
||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||
const genreOptions = computed(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const counts = new Map()
|
||||
for (const movie of pool) {
|
||||
@@ -213,6 +255,7 @@ const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
...(host.value
|
||||
? []
|
||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
@@ -223,6 +266,30 @@ const tabs = computed(() => [
|
||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||
])
|
||||
|
||||
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
|
||||
const suchFilter = computed(() => {
|
||||
const spanne = (key) => ranges.value[key] || []
|
||||
return {
|
||||
sort: suchSort.value,
|
||||
include: include.value,
|
||||
exclude: exclude.value,
|
||||
stimmen: spanne('vote_count'),
|
||||
dauer: spanne('runtime'),
|
||||
jahr: spanne('year'),
|
||||
note: spanne('vote_average'),
|
||||
grenzen: grenzen.value,
|
||||
}
|
||||
})
|
||||
|
||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||
const aehnlichStart = ref(null)
|
||||
|
||||
function zurAehnlichkeitsuche(movie) {
|
||||
aehnlichStart.value = movie
|
||||
selected.value = null
|
||||
activeTab.value = 'aehnlich'
|
||||
}
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
||||
@@ -515,7 +582,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="page" :class="{
|
||||
breit: [
|
||||
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
].includes(activeTab),
|
||||
}">
|
||||
<nav class="tabs">
|
||||
@@ -533,7 +600,39 @@ onUnmounted(() => {
|
||||
<span v-else class="host">Host</span>
|
||||
</nav>
|
||||
|
||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
|
||||
<SearchTab
|
||||
v-if="activeTab === 'search'"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
|
||||
<AehnlichTab
|
||||
v-else
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
:start="aehnlichStart"
|
||||
@open="filmOeffnen"
|
||||
@gestartet="aehnlichStart = null"
|
||||
/>
|
||||
|
||||
<FilterSidebar
|
||||
v-model:sort="suchSort"
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ranges="ranges"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
:options="genreOptions"
|
||||
:include="include"
|
||||
:exclude="exclude"
|
||||
:ohne-suche="true"
|
||||
:mit-relevanz="true"
|
||||
@toggle="toggleGenre"
|
||||
@all="toggleAll"
|
||||
@reset="resetGenres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WheelTab
|
||||
v-else-if="activeTab === 'wheel'"
|
||||
@@ -591,7 +690,7 @@ onUnmounted(() => {
|
||||
:movie="entry.parts[0]"
|
||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
@@ -599,7 +698,7 @@ onUnmounted(() => {
|
||||
:start="entry.best"
|
||||
:auf-dem-rad="aufDemRad"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -631,7 +730,7 @@ onUnmounted(() => {
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
@@ -639,6 +738,7 @@ onUnmounted(() => {
|
||||
@gemerkt="ladeMerkliste"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
@aehnlich="zurAehnlichkeitsuche"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -662,10 +762,11 @@ onUnmounted(() => {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: stretch;
|
||||
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
|
||||
Karten oberhalb der Leiste durchs Bild */
|
||||
margin: -1.5rem -1.25rem 1.25rem;
|
||||
padding: 0.7rem 1.25rem 0;
|
||||
padding: 0 1.25rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@@ -694,7 +795,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem 1rem;
|
||||
padding: 0.8rem 1rem 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
288
frontend/src/components/AehnlichTab.vue
Normal file
288
frontend/src/components/AehnlichTab.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { 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
|
||||
|
||||
const props = defineProps({
|
||||
filter: { type: Object, default: () => ({}) },
|
||||
ansicht: { type: String, default: 'kompakt' },
|
||||
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
|
||||
start: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open', 'gestartet'])
|
||||
|
||||
const query = ref('')
|
||||
const vorschlaege = ref([])
|
||||
const gewaehlt = ref(null)
|
||||
const treffer = ref([])
|
||||
const laden = ref(false)
|
||||
const fehler = ref('')
|
||||
|
||||
let timer = null
|
||||
let filterTimer = null
|
||||
let laufende = 0
|
||||
let laufendeWahl = 0
|
||||
let laufendeTreffer = 0
|
||||
|
||||
// Erst suchen, dann einen Film auswählen — danach zeigt der Reiter, was
|
||||
// dazu passt.
|
||||
async function suchen(term) {
|
||||
if (term.trim().length < 2) {
|
||||
vorschlaege.value = []
|
||||
return
|
||||
}
|
||||
const meine = ++laufende
|
||||
try {
|
||||
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
|
||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||
const daten = await resp.json()
|
||||
if (meine !== laufende) return
|
||||
vorschlaege.value = daten.slice(0, 8)
|
||||
} catch (err) {
|
||||
if (meine === laufende) fehler.value = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function waehlen(film) {
|
||||
const meine = ++laufendeWahl
|
||||
gewaehlt.value = film
|
||||
vorschlaege.value = []
|
||||
query.value = ''
|
||||
nachladen()
|
||||
|
||||
// Den vollen Datensatz holen, damit die erste Karte alles zeigt.
|
||||
// Läuft neben den Ähnlichen her, nicht davor.
|
||||
try {
|
||||
const resp = await fetch(`/api/search/${film.id}`)
|
||||
if (resp.ok && meine === laufendeWahl) gewaehlt.value = await resp.json()
|
||||
} catch {
|
||||
/* dann bleibt der Datensatz aus der Trefferliste */
|
||||
}
|
||||
}
|
||||
|
||||
// Die Filter gehen ans Backend: /recommendations kennt sie nicht, also
|
||||
// blättert es dort so lange nach, bis zwanzig passende beisammen sind.
|
||||
async function nachladen() {
|
||||
const film = gewaehlt.value
|
||||
if (!film) return
|
||||
const meine = ++laufendeTreffer
|
||||
// Erst wegräumen: sonst stehen unter der neuen Überschrift noch die
|
||||
// Treffer von vorhin, bis die Antwort da ist
|
||||
treffer.value = []
|
||||
laden.value = true
|
||||
fehler.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: TREFFER,
|
||||
voll: 'true',
|
||||
...filterParameter(props.filter),
|
||||
})
|
||||
const resp = await fetch(`/api/movies/${film.id}/aehnliche?${params}`)
|
||||
if (!resp.ok) throw new Error('Konnte nichts Ähnliches finden')
|
||||
const daten = await resp.json()
|
||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||
if (meine !== laufendeTreffer) return
|
||||
treffer.value = daten
|
||||
} catch (err) {
|
||||
if (meine === laufendeTreffer) fehler.value = err.message
|
||||
} finally {
|
||||
if (meine === laufendeTreffer) laden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
|
||||
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
|
||||
function passt(movie) {
|
||||
const werte = props.filter.dauer
|
||||
const grenze = props.filter.grenzen?.runtime
|
||||
if (!werte || !grenze || movie.runtime == null) return true
|
||||
if (werte[0] > grenze.min && movie.runtime < werte[0]) return false
|
||||
if (werte[1] < grenze.max && movie.runtime > werte[1]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.start,
|
||||
(film) => {
|
||||
if (!film) return
|
||||
waehlen(film)
|
||||
emit('gestartet')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
||||
watch(
|
||||
() => props.filter,
|
||||
() => {
|
||||
if (!gewaehlt.value) return
|
||||
clearTimeout(filterTimer)
|
||||
filterTimer = setTimeout(nachladen, 350)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(query, (term) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => suchen(term), 300)
|
||||
})
|
||||
|
||||
async function oeffnen(movie) {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aehnlich-tab">
|
||||
<!-- Die Suchleiste bleibt oben stehen: von hier geht es jederzeit
|
||||
zu einem anderen Ausgangsfilm -->
|
||||
<div class="suchzeile">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="big-search"
|
||||
placeholder="Von welchem Film ausgehen?"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
type="button"
|
||||
class="leeren"
|
||||
title="Suche leeren"
|
||||
@click="query = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tippt jemand, stehen die Vorschläge vorn, bis einer gewählt ist -->
|
||||
<template v-if="query.trim().length >= 2">
|
||||
<p v-if="!vorschlaege.length" class="note">Nichts gefunden.</p>
|
||||
<div v-else class="grid" :class="ansicht">
|
||||
<MovieCard
|
||||
v-for="film in vorschlaege"
|
||||
:key="film.id"
|
||||
:movie="film"
|
||||
:ansicht="ansicht"
|
||||
@open="waehlen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="gewaehlt">
|
||||
<p class="label">Ähnlich wie „{{ gewaehlt.title }}“</p>
|
||||
|
||||
<p v-if="laden" class="note">Suche läuft…</p>
|
||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!treffer.filter(passt).length" class="note">
|
||||
Nichts gefunden. Stell die Filter weiter.
|
||||
</p>
|
||||
|
||||
<div v-if="!laden" class="grid" :class="ansicht">
|
||||
<MovieCard
|
||||
:movie="gewaehlt"
|
||||
:ansicht="ansicht"
|
||||
class="ausgangsfilm"
|
||||
@open="oeffnen"
|
||||
/>
|
||||
<MovieCard
|
||||
v-for="film in treffer.filter(passt)"
|
||||
:key="film.id"
|
||||
:movie="film"
|
||||
:ansicht="ansicht"
|
||||
@open="oeffnen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else class="note">
|
||||
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aehnlich-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);
|
||||
}
|
||||
|
||||
.jahr {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: #f5c518;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Der Ausgangsfilm steht vorn und hebt sich ab */
|
||||
.ausgangsfilm {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px 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))
|
||||
);
|
||||
}
|
||||
|
||||
.aehnlich-tab > .note {
|
||||
display: block;
|
||||
padding: 2rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aehnlich-tab > .note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
exclude: { type: Array, required: true },
|
||||
regler: { type: Array, default: () => [] },
|
||||
grenzen: { type: Object, default: () => ({}) },
|
||||
// Der Suche-Reiter hat ein eigenes, großes Suchfeld über der Liste
|
||||
ohneSuche: Boolean,
|
||||
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
||||
mitRelevanz: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
@@ -46,8 +50,14 @@ function beschriften(regler) {
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="controls">
|
||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<input
|
||||
v-if="!ohneSuche"
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Titel suchen…"
|
||||
/>
|
||||
<select v-model="sort">
|
||||
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||
<option value="random">Zufällig</option>
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
|
||||
@@ -42,7 +42,7 @@ async function oeffnen(item) {
|
||||
<template>
|
||||
<div class="merkliste-tab">
|
||||
<p v-if="!items.length" class="note">
|
||||
Noch nichts gemerkt. Öffne einen Film und klicke auf „Merken“.
|
||||
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
|
||||
</p>
|
||||
|
||||
<section v-for="gruppe in gruppen" :key="gruppe.titel">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -11,7 +11,9 @@ const props = defineProps({
|
||||
gemerkt: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
|
||||
const emit = defineEmits([
|
||||
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
|
||||
])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit' })
|
||||
|
||||
@@ -19,6 +21,18 @@ const schonVorgeschlagen = computed(
|
||||
() => props.meinVorschlag?.movie_id === props.movie.id
|
||||
)
|
||||
|
||||
// Der eigene Zustand geht vor: nach einem Klick steht der Knopf sofort
|
||||
// richtig, ohne auf die neu geladene Vorschlagsliste zu warten.
|
||||
const aufDemRad = computed(() => {
|
||||
if (vorschlagen.value.zustand === 'fertig') return true
|
||||
if (vorschlagen.value.zustand === 'weg') return false
|
||||
return schonVorgeschlagen.value
|
||||
})
|
||||
|
||||
function radUmschalten() {
|
||||
return aufDemRad.value ? zuruecknehmen() : abschicken()
|
||||
}
|
||||
|
||||
async function merkenUmschalten() {
|
||||
const weg = props.gemerkt
|
||||
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
||||
@@ -34,7 +48,7 @@ async function zuruecknehmen() {
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
|
||||
if (!resp.ok) throw new Error()
|
||||
vorschlagen.value = { zustand: 'bereit' }
|
||||
vorschlagen.value = { zustand: 'weg' }
|
||||
emit('suggested')
|
||||
} catch {
|
||||
vorschlagen.value = { zustand: 'fehler' }
|
||||
@@ -80,6 +94,28 @@ const shots = computed(() =>
|
||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||
)
|
||||
|
||||
// Was Leute noch geschaut haben, die diesen Film mochten
|
||||
const aehnliche = ref([])
|
||||
|
||||
async function ladeAehnliche(id) {
|
||||
aehnliche.value = []
|
||||
try {
|
||||
const resp = await fetch(`/api/movies/${id}/aehnliche`)
|
||||
if (resp.ok && props.movie.id === id) aehnliche.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.movie.id,
|
||||
(id) => {
|
||||
vorschlagen.value = { zustand: 'bereit' }
|
||||
ladeAehnliche(id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
|
||||
const grossesBild = ref(null)
|
||||
|
||||
@@ -107,13 +143,23 @@ function openPart(part) {
|
||||
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
// Die Sperre muss aufs Wurzelelement: den Bildlauf macht hier <html>,
|
||||
// und an <body> allein läuft die Seite hinter der Ansicht weiter mit.
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKey)
|
||||
const wurzel = document.documentElement
|
||||
// Mit der Sperre verschwindet die Bildlaufleiste. Ohne Ausgleich
|
||||
// rutscht die ganze Seite um deren Breite zur Seite.
|
||||
const leiste = window.innerWidth - wurzel.clientWidth
|
||||
wurzel.style.overflow = 'hidden'
|
||||
wurzel.style.paddingRight = leiste ? `${leiste}px` : ''
|
||||
document.body.style.overflow = 'hidden'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.documentElement.style.overflow = ''
|
||||
document.documentElement.style.paddingRight = ''
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
@@ -141,7 +187,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||
<div class="spalte-links">
|
||||
<div class="links">
|
||||
<a
|
||||
v-if="movie.on_netflix"
|
||||
class="netflix"
|
||||
@@ -162,31 +208,28 @@ onUnmounted(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="!host" class="vorschlag">
|
||||
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
|
||||
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
|
||||
<div class="vorschlag">
|
||||
<button type="button" class="merken" @click="$emit('aehnlich', movie)">
|
||||
Ähnliche finden
|
||||
</button>
|
||||
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
|
||||
<span class="ok">Steht auf dem Rad.</span>
|
||||
<button
|
||||
v-if="!host"
|
||||
type="button"
|
||||
class="merken"
|
||||
:class="{ an: gemerkt }"
|
||||
@click="merkenUmschalten"
|
||||
>
|
||||
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
|
||||
</button>
|
||||
<template v-if="!host">
|
||||
<button
|
||||
type="button"
|
||||
class="merken"
|
||||
:class="{ an: aufDemRad }"
|
||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||
@click="zuruecknehmen"
|
||||
@click="radUmschalten"
|
||||
>
|
||||
Wieder runternehmen
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="meinVorschlag" class="hinweis">
|
||||
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
|
||||
Vorschlag ersetzt ihn.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||
@click="abschicken"
|
||||
>
|
||||
Vorschlagen
|
||||
{{ aufDemRad ? 'Auf dem Rad' : 'Zum Rad' }}
|
||||
</button>
|
||||
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||||
Hat nicht geklappt.
|
||||
@@ -213,8 +256,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="links">
|
||||
<section v-if="series.length">
|
||||
<section v-if="movie.overview" class="handlung">
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
<section v-if="series.length" class="reihe">
|
||||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||||
<div class="series">
|
||||
<button
|
||||
@@ -238,11 +284,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="movie.overview">
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="trailer" class="rechts">
|
||||
<div class="video">
|
||||
@@ -255,6 +296,28 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="aehnliche.length" class="aehnliche">
|
||||
<h3>Ähnliche Filme</h3>
|
||||
<div class="series">
|
||||
<button
|
||||
v-for="film in aehnliche"
|
||||
:key="film.id"
|
||||
class="part"
|
||||
type="button"
|
||||
@click="$emit('open', film)"
|
||||
>
|
||||
<img
|
||||
v-if="posterUrl(film.poster_path, 'w154')"
|
||||
:src="posterUrl(film.poster_path, 'w154')"
|
||||
:alt="film.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="no-poster">Kein Cover</span>
|
||||
<span class="part-title">{{ film.title }}</span>
|
||||
<span class="part-year">{{ film.year }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="shots.length" class="shots-section">
|
||||
<div class="shots">
|
||||
<button
|
||||
@@ -274,9 +337,14 @@ onUnmounted(() => {
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||||
<img :src="grossesBild" :alt="movie.title" />
|
||||
</div>
|
||||
<!-- Muss aus der Ansicht heraus: deren backdrop-filter macht sie zum
|
||||
Bezugsrahmen, und "fixed" richtet sich dann an ihr aus statt am
|
||||
Fenster — das Bild landete am Anfang der Ansicht statt im Blick. -->
|
||||
<Teleport to="body">
|
||||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||||
<img :src="grossesBild" :alt="movie.title" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -288,6 +356,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
/* Am unteren Ende soll der Bildlauf stehen bleiben, nicht auf die
|
||||
Seite dahinter überspringen */
|
||||
overscroll-behavior: contain;
|
||||
padding: 2rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -307,17 +378,22 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
grid-column: 1;
|
||||
/* Oben links Cover, Text und Knöpfe, rechts daneben der Trailer. Darunter
|
||||
über die ganze Breite: Handlung, Reihe, Ähnliche, Bilder. */
|
||||
.sheet > header {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.sheet > .rechts {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-area: 1 / 2 / span 2;
|
||||
}
|
||||
|
||||
.sheet > .handlung {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
|
||||
.sheet > .reihe,
|
||||
.sheet > .aehnliche,
|
||||
.sheet > .shots-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -327,10 +403,12 @@ onUnmounted(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sheet > .rechts,
|
||||
.sheet > .shots-section,
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
.sheet > .rechts,
|
||||
.sheet > .handlung,
|
||||
.sheet > .reihe,
|
||||
.sheet > .aehnliche,
|
||||
.sheet > .shots-section {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
@@ -403,7 +481,7 @@ h2 {
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.netflix,
|
||||
@@ -424,10 +502,13 @@ h2 {
|
||||
background: #f5c518;
|
||||
}
|
||||
|
||||
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||
.vorschlag {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
@@ -473,7 +554,8 @@ h2 {
|
||||
.gesehen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
frontend/src/filter.js
Normal file
24
frontend/src/filter.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Die Filterleiste steht auf Anschlag, solange niemand sie anfasst. Genau
|
||||
// dann darf sie auch nichts wegschneiden — also wandert nur ins Backend,
|
||||
// was tatsächlich verstellt wurde.
|
||||
function spanne(filter, name, schluessel, ziel) {
|
||||
const werte = filter[name]
|
||||
const grenze = filter.grenzen?.[schluessel]
|
||||
if (!werte || !grenze) return {}
|
||||
const raus = {}
|
||||
if (werte[0] > grenze.min) raus[`${ziel}_min`] = werte[0]
|
||||
if (werte[1] < grenze.max) raus[`${ziel}_max`] = werte[1]
|
||||
return raus
|
||||
}
|
||||
|
||||
export function filterParameter(filter) {
|
||||
return {
|
||||
sort: filter.sort || 'rating',
|
||||
include: (filter.include || []).join(','),
|
||||
exclude: (filter.exclude || []).join(','),
|
||||
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
||||
...spanne(filter, 'dauer', 'runtime', 'dauer'),
|
||||
...spanne(filter, 'jahr', 'year', 'jahr'),
|
||||
...spanne(filter, 'note', 'vote_average', 'note'),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user