update
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import SearchTab from './components/SearchTab.vue'
|
||||
import WheelTab from './components/WheelTab.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
|
||||
const movies = ref([])
|
||||
@@ -12,14 +15,156 @@ const search = ref('')
|
||||
const sort = ref('rating')
|
||||
const selected = ref(null)
|
||||
const activeTab = ref('netflix')
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||
const canonMovies = computed(() =>
|
||||
movies.value.filter((m) => m.canon_group !== null)
|
||||
)
|
||||
|
||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
|
||||
// zusammen, damit die Karte durch ihre Teile blättern kann.
|
||||
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||
// sonst wandern die Enden bei jeder Auswahl
|
||||
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: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
|
||||
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
|
||||
]
|
||||
|
||||
const grenzen = computed(() => {
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const out = {}
|
||||
for (const regler of REGLER) {
|
||||
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] = {
|
||||
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
|
||||
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const ranges = ref({})
|
||||
|
||||
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
|
||||
watch(
|
||||
grenzen,
|
||||
(neu) => {
|
||||
ranges.value = Object.fromEntries(
|
||||
Object.entries(neu).map(([k, v]) => [k, [v.min, v.max]])
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function resetRanges() {
|
||||
ranges.value = Object.fromEntries(
|
||||
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
|
||||
)
|
||||
}
|
||||
|
||||
function imBereich(movie) {
|
||||
for (const regler of REGLER) {
|
||||
const spanne = ranges.value[regler.key]
|
||||
const grenze = grenzen.value[regler.key]
|
||||
if (!spanne) continue
|
||||
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
|
||||
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
|
||||
const wert = movie[regler.feld]
|
||||
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleGenre(kind, genre) {
|
||||
const liste = kind === 'include' ? include : exclude
|
||||
const andere = kind === 'include' ? exclude : include
|
||||
const at = liste.value.indexOf(genre)
|
||||
if (at === -1) {
|
||||
liste.value = [...liste.value, genre]
|
||||
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
|
||||
andere.value = andere.value.filter((g) => g !== genre)
|
||||
} else {
|
||||
liste.value = liste.value.filter((g) => g !== genre)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(kind) {
|
||||
const liste = kind === 'include' ? include : exclude
|
||||
const andere = kind === 'include' ? exclude : include
|
||||
const alle = genreOptions.value.map((o) => o.genre)
|
||||
// Sind schon alle drin, ist der zweite Klick ein Leeren
|
||||
if (liste.value.length === alle.length) {
|
||||
liste.value = []
|
||||
} else {
|
||||
liste.value = alle
|
||||
andere.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function resetGenres() {
|
||||
include.value = []
|
||||
exclude.value = []
|
||||
resetRanges()
|
||||
}
|
||||
|
||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||
const genreOptions = computed(() => {
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const counts = new Map()
|
||||
for (const movie of pool) {
|
||||
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
})
|
||||
|
||||
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
|
||||
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
|
||||
function groupBy(movies, schluessel) {
|
||||
const byKey = new Map()
|
||||
const einzeln = []
|
||||
for (const movie of movies) {
|
||||
const key = schluessel(movie)
|
||||
if (key === null || key === undefined) {
|
||||
einzeln.push(movie)
|
||||
continue
|
||||
}
|
||||
if (!byKey.has(key)) byKey.set(key, [])
|
||||
byKey.get(key).push(movie)
|
||||
}
|
||||
|
||||
const gruppen = []
|
||||
for (const [key, parts] of byKey) {
|
||||
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
|
||||
if (parts.length === 1) einzeln.push(parts[0])
|
||||
else gruppen.push({ group: key, parts })
|
||||
}
|
||||
return { gruppen, einzeln }
|
||||
}
|
||||
|
||||
function sortiereReihe(parts) {
|
||||
const sorted = [...parts].sort(
|
||||
(a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
|
||||
)
|
||||
// Die Karte startet beim stärksten Teil, danach sortiert auch die
|
||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
|
||||
let best = 0
|
||||
sorted.forEach((part, i) => {
|
||||
if (score(part) > score(sorted[best])) best = i
|
||||
})
|
||||
return { parts: sorted, best }
|
||||
}
|
||||
|
||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
|
||||
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
|
||||
// Jahr — hier zählt das Erscheinungsjahr.
|
||||
const canonGroups = computed(() => {
|
||||
const byGroup = new Map()
|
||||
for (const movie of canonMovies.value) {
|
||||
@@ -28,30 +173,46 @@ const canonGroups = computed(() => {
|
||||
}
|
||||
return [...byGroup.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([group, parts]) => {
|
||||
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
|
||||
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
|
||||
const sorted = parts.sort(
|
||||
(a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||
a.canon_pos - b.canon_pos
|
||||
)
|
||||
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
|
||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt über
|
||||
// den Platz der ganzen Reihe
|
||||
let best = 0
|
||||
sorted.forEach((part, i) => {
|
||||
if (score(part) > score(sorted[best])) best = i
|
||||
})
|
||||
return { group, parts: sorted, best }
|
||||
})
|
||||
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
|
||||
})
|
||||
|
||||
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
|
||||
const netflixEntries = computed(() => {
|
||||
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
|
||||
return [
|
||||
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
|
||||
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
|
||||
]
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
])
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||
const meinVorschlag = ref(null)
|
||||
|
||||
async function ladeVorschlag() {
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions')
|
||||
if (!resp.ok) return
|
||||
const daten = await resp.json()
|
||||
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
|
||||
} catch {
|
||||
meinVorschlag.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function nachVorschlag() {
|
||||
ladeVorschlag()
|
||||
rad.value?.neu()
|
||||
}
|
||||
|
||||
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
||||
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||
const MIN_VOTES = 150
|
||||
@@ -84,25 +245,42 @@ function matches(movie, term) {
|
||||
)
|
||||
}
|
||||
|
||||
const visible = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? netflixMovies.value.filter((m) => matches(m, term))
|
||||
: [...netflixMovies.value]
|
||||
return list.sort(SORTS[sort.value])
|
||||
})
|
||||
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
|
||||
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
|
||||
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||
function passt(movie) {
|
||||
return hasGenre(movie) && imBereich(movie)
|
||||
}
|
||||
|
||||
function hasGenre(movie) {
|
||||
const genres = movie.genres || []
|
||||
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
|
||||
return false
|
||||
}
|
||||
return !include.value.length || genres.some((g) => include.value.includes(g))
|
||||
}
|
||||
|
||||
// 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)))
|
||||
)
|
||||
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
|
||||
}
|
||||
|
||||
const visible = computed(() =>
|
||||
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(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? canonGroups.value.filter((g) => g.parts.some((m) => matches(m, term)))
|
||||
: [...canonGroups.value]
|
||||
return list.sort((a, b) =>
|
||||
SORTS[sort.value](a.parts[a.best], b.parts[b.best])
|
||||
)
|
||||
})
|
||||
const visibleCanon = computed(() =>
|
||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
||||
)
|
||||
|
||||
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
||||
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
||||
@@ -127,14 +305,6 @@ const currentCount = computed(() =>
|
||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
||||
)
|
||||
|
||||
const lastSync = computed(() => {
|
||||
if (!status.value?.last_sync) return null
|
||||
return new Date(status.value.last_sync).toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -153,27 +323,14 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
ladeVorschlag()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<h1>Horrorfilme</h1>
|
||||
<p v-if="lastSync" class="sync">Stand {{ lastSync }}</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<select v-model="sort">
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@@ -183,79 +340,90 @@ onMounted(load)
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="count">{{ tab.count }}</span>
|
||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!currentCount" class="note">Nichts gefunden für „{{ search }}“.</p>
|
||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||
|
||||
<div v-if="activeTab === 'netflix'" class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in visible"
|
||||
:key="movie.id"
|
||||
:movie="movie"
|
||||
@open="selected = $event"
|
||||
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="results">
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!currentCount" class="note">
|
||||
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<template
|
||||
v-for="entry in activeTab === 'netflix' ? visible : visibleCanon"
|
||||
:key="entry.group"
|
||||
>
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:start="entry.best"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterSidebar
|
||||
v-if="genreOptions.length"
|
||||
v-model:search="search"
|
||||
v-model:sort="sort"
|
||||
v-model:ranges="ranges"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
:options="genreOptions"
|
||||
:include="include"
|
||||
:exclude="exclude"
|
||||
@toggle="toggleGenre"
|
||||
@all="toggleAll"
|
||||
@reset="resetGenres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<template v-for="entry in visibleCanon" :key="entry.group">
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:start="entry.best"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<MovieDetail
|
||||
v-if="selected"
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
@suggested="nachVorschlag"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
Daten von
|
||||
<a href="https://www.themoviedb.org" target="_blank" rel="noopener">TMDB</a>
|
||||
und JustWatch. This product uses the TMDB API but is not endorsed or certified
|
||||
by TMDB.
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
padding-right: calc(var(--sidebar) + 1.25rem);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
/* Such- und Rad-Reiter haben keine Filterleiste */
|
||||
.page.breit {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -286,29 +454,45 @@ onMounted(load)
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sync {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Ohne min-width schiebt das Suchfeld auf schmalen Fenstern die Seite breit */
|
||||
.controls input {
|
||||
flex: 1 1 12rem;
|
||||
.layout {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.results {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layout > :last-child {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar);
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
/* Zu schmal für eine feste Leiste — die Filter wandern über die Kacheln */
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout > :last-child {
|
||||
position: static;
|
||||
order: -1;
|
||||
width: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
@@ -325,13 +509,4 @@ h1 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user