Files
horror/frontend/src/App.vue
2026-10-08 16:03:27 +02:00

861 lines
22 KiB
Vue

<script setup>
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue'
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 ClipsTab from './components/ClipsTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue'
import FilmLogin from './components/FilmLogin.vue'
import VerwaltungTab from './components/VerwaltungTab.vue'
import WuenscheTab from './components/WuenscheTab.vue'
import InfoTab from './components/InfoTab.vue'
import SearchTab from './components/SearchTab.vue'
import StreamTab from './components/StreamTab.vue'
import WatchedTab from './components/WatchedTab.vue'
import WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.vue'
import { useEinstellungen } from './composables/useEinstellungen'
import { useMeins } from './composables/useMeins'
import { matches, useKatalog } from './composables/useKatalog'
import { REGLER, useFilter } from './composables/useFilter'
import { useLangeListe } from './composables/useLangeListe'
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden } =
useEinstellungen()
const {
runde,
ich,
michSetzen,
gesehen,
gesehenIds,
gesehenZahl,
ladeGesehen,
meinVorschlag,
aufDemRad,
ladeVorschlag,
merkliste,
ladeMerkliste,
wuensche,
ladeWuensche,
} = useMeins({ host })
// Das Level zu einem Namen — für die Badges vor Namen überall auf der Seite
provide('levelVon', (name) => runde.value.find((u) => u.name === name)?.level ?? null)
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die
// Reiterliste fragt danach.
const angemeldet = computed(() => !!ich.value)
const {
movies,
loading,
error,
load,
netflixMovies,
canonMovies,
canonGroups,
netflixEntries,
SORTS,
} = useKatalog()
const {
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))),
)
.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(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase()),
)
const aktuelleListe = computed(() =>
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: 'canon',
label: 'Horror-Kanon',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
},
])
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
liste: aktuelleListe,
ansicht,
})
const tabs = computed(() => [
{
id: 'katalog',
label: 'Katalog',
count: quellen.value.find((q) => q.id === quelle.value)?.count ?? null,
},
{ id: 'suche', label: 'Suche', count: null },
{ id: 'merkliste', label: 'Sammlungen', count: null },
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
{ id: 'clips', label: 'Clips', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{
id: 'wuensche',
label: 'Wünsche',
count: wuensche.value.filter((w) => !w.done_at).length,
},
{ id: 'info', label: 'Info', count: null },
...(host.value || angemeldet.value
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
: []),
])
// 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))
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
// dafür hat er eine eigene Leiste
const sammlungLeute = ref([])
const sammlungHorror = ref(null)
// Wer überhaupt etwas gesammelt hat, mit Anzahl. Die eigene zuerst.
const leuteMitSammlung = computed(() => {
const nach = new Map()
for (const eintrag of merkliste.value) {
const name = eintrag.name || 'Anonym'
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'))
})
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
// sonst keiner markiert und die Seite bliebe leer
watch(tabs, (liste) => {
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'katalog'
})
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
const reiter = ref(null)
watch(activeTab, async () => {
filterOffen.value = false
await nextTick()
const leiste = reiter.value
if (!leiste || leiste.scrollWidth <= leiste.clientWidth) return
leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' })
})
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 */
}
}
// 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.
const series = computed(() => {
const movie = selected.value
if (!movie) return []
const group =
movie.canon_group !== null
? canonMovies.value.filter((m) => m.canon_group === movie.canon_group)
: movie.collection_id
? netflixMovies.value.filter((m) => m.collection_id === movie.collection_id)
: []
if (group.length < 2) return []
return [...group].sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''))
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
const aehnlichStart = ref(null)
function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie
selected.value = null
activeTab.value = 'suche'
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' },
]
const rad = ref(null)
function nachVorschlag() {
ladeVorschlag()
rad.value?.neu()
}
onMounted(load)
</script>
<template>
<div class="page" :class="{ breit: !mitFilter }">
<header class="kopf">
<nav ref="reiter" class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: tab.id === activeTab }"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
</nav>
<div class="rechts">
<button
v-if="mitFilter"
type="button"
class="filter-knopf"
:class="{ an: filterOffen }"
@click="filterOffen = !filterOffen"
>
Filter
</button>
<span v-if="host" class="host">Host</span>
<NamensWahl :items="runde" :mine="ich" :host="host" :anmelden="michSetzen" />
</div>
</header>
<div v-if="activeTab === 'suche'" class="layout">
<div class="suche">
<div class="sucharten">
<button
v-for="art in SUCHARTEN"
:key="art.id"
type="button"
:class="{ active: suchArt === art.id }"
@click="suchArt = art.id"
>
{{ art.label }}
</button>
</div>
<SearchTab
v-if="suchArt === 'search'"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
<AehnlichTab
v-else-if="suchArt === 'aehnlich'"
:gesehen-ids="gesehenIds"
:filter="suchFilter"
:ansicht="ansicht"
:start="aehnlichStart"
@open="filmOeffnen"
@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"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
</div>
<!-- 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"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:ohne-suche="true"
:mit-relevanz="suchArt !== 'ki'"
:ohne-sort="suchArt === 'ki'"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<WheelTab
v-else-if="activeTab === 'wheel'"
ref="rad"
:host="host"
:angemeldet="angemeldet"
:ich="ich"
:merkliste="merkliste"
:mine="meinVorschlag?.id ?? null"
:gesehen-ids="gesehenIds"
@open="selected = $event"
@reset="meinVorschlag = null"
/>
<WatchedTab
v-else-if="activeTab === 'watched'"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
:ich="ich"
@open="selected = $event"
@changed="ladeGesehen"
/>
<div v-else-if="activeTab === 'merkliste'" class="layout">
<MerklisteTab
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
:ansicht="ansicht"
:leute="sammlungLeute"
:horror="sammlungHorror"
@open="filmOeffnen"
@changed="ladeMerkliste"
/>
<SammlungFilter
v-model:gewaehlt="sammlungLeute"
v-model:horror="sammlungHorror"
v-model:ansicht="ansicht"
:class="{ offen: filterOffen }"
:leute="leuteMitSammlung"
/>
</div>
<WuenscheTab
v-else-if="activeTab === 'wuensche'"
:items="wuensche"
:ich="ich"
:host="host"
:angemeldet="angemeldet"
@changed="ladeWuensche"
/>
<InfoTab v-else-if="activeTab === 'info'" :host="host" :runde="runde" />
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
<ClipsTab v-else-if="activeTab === 'clips'" :angemeldet="angemeldet" :host="host" />
<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="!aktuelleListe.length" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p>
<div class="grid" :class="ansicht">
<template v-for="entry in sichtbar" :key="entry.group">
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
:gesehen="gesehenIds.has(entry.parts[0].id)"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht"
@open="filmOeffnen"
/>
<SeriesCard
v-else
:parts="entry.parts"
:gesehen-ids="gesehenIds"
:start="entry.best"
:auf-dem-rad="aufDemRad"
:ansicht="ansicht"
@open="filmOeffnen"
/>
</template>
</div>
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
Lädt weitere Filme …
</div>
</div>
<FilterSidebar
v-if="genreOptions.length"
v-model:quelle="quelle"
v-model:search="search"
v-model:sort="sort"
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:quellen="quellen"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<button v-if="obenKnopf" type="button" class="nach-oben" title="Nach oben" @click="nachOben">
↑
</button>
<button
v-if="filterOffen && mitFilter"
type="button"
class="filter-fertig"
@click="filterOffen = false"
>
Fertig
</button>
<!-- Die Tastenkombination ohne Freischaltung: den Host-Film finden -->
<FilmLogin
v-if="hostLogin"
name="Host"
:pruefen="hostAnmelden"
@abbrechen="hostLogin = false"
/>
<MovieDetail
v-if="selected"
:movie="selected"
:series="series"
:mein-vorschlag="meinVorschlag"
:host="host"
:angemeldet="angemeldet"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
@close="selected = null"
@open="filmOeffnen"
@gemerkt="ladeMerkliste"
@suggested="nachVorschlag"
@watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
@person="zurPersonensuche"
/>
</div>
</template>
<style scoped>
.page {
/* Platz für die Leiste, die ganz rechts am Fensterrand klebt */
padding: 1.5rem 1.25rem 3rem;
padding-right: calc(var(--sidebar) + 1.25rem);
}
/* Reiter ohne Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
.kopf {
position: sticky;
top: 0;
z-index: 10;
display: flex;
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 1.25rem;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.tabs {
display: flex;
gap: 0.35rem;
align-items: stretch;
min-width: 0;
/* Zehn Reiter passen nicht auf jeden Schirm — dann rollt die Leiste,
statt unter den Namen zu laufen */
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.rechts {
display: flex;
gap: 0.4rem;
align-items: center;
margin-left: auto;
}
.filter-fertig {
display: none;
}
.nach-oben {
position: fixed;
right: calc(var(--sidebar) + 1.25rem);
bottom: 1.5rem;
z-index: 15;
width: 3rem;
height: 3rem;
color: #fff;
font-size: 1.35rem;
line-height: 1;
background: var(--accent);
border: 0;
border-radius: 50%;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
.nach-oben:hover {
filter: brightness(1.15);
}
.tabs button {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.8rem 1rem 0.6rem;
color: var(--muted);
font-size: 0.95rem;
white-space: nowrap;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
/* Den Filterknopf gibt es nur auf schmalen Schirmen */
.filter-knopf {
display: none;
padding: 0.35rem 0.8rem;
color: var(--text);
font-size: 0.85rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-knopf.an {
color: var(--accent);
border-color: var(--accent);
}
.host {
padding: 0.15rem 0.5rem;
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--accent);
border-radius: 4px;
}
.tabs .count {
padding: 0.1rem 0.45rem;
font-size: 0.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.layout {
min-width: 0;
}
.suche {
min-width: 0;
}
/* Umschalter zwischen den drei Suchen: drei gleich breite Knöpfe über
die volle Breite, im Stil von Kompakt/Details in der Leiste */
.sucharten {
display: flex;
gap: 0.4rem;
margin-bottom: 1.2rem;
}
.sucharten button {
flex: 1;
padding: 0.6rem 0.9rem;
color: var(--muted);
font-size: 0.9rem;
white-space: nowrap;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.sucharten button:hover {
color: var(--text);
}
.sucharten button.active {
color: var(--text);
font-weight: 600;
border-color: var(--accent);
}
.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);
}
.nachschub {
padding: 2rem 0;
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
/* 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)));
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
/* ── Schmale Schirme ─────────────────────────────────────────────── */
@media (max-width: 900px) {
.page {
padding-right: 1.25rem;
}
.nach-oben {
right: 1rem;
bottom: 1rem;
}
.layout {
display: flex;
flex-direction: column;
}
/* Zu schmal für eine feste Leiste — die Filter legen sich als ganze
Seite darüber, ein Balken unten schließt sie wieder */
.layout > :last-child {
display: none;
}
.layout > :last-child.offen {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
width: auto;
padding-bottom: 5rem;
overflow-y: auto;
border: 0;
}
.filter-fertig {
position: fixed;
right: 1rem;
bottom: 1rem;
left: 1rem;
z-index: 21;
display: block;
padding: 0.9rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
.filter-knopf {
display: block;
}
}
@media (max-width: 640px) {
.page {
padding: 0.75rem 0.75rem 2.5rem;
}
/* Zwei Zeilen: oben rechts Filter und Name, darunter die Reiter, die
seitlich scrollen. Der Verlauf rechts zeigt, dass da noch mehr kommt. */
.kopf {
flex-wrap: wrap;
margin: -0.75rem -0.75rem 0.9rem;
padding: 0 0.75rem;
}
.rechts {
order: -1;
justify-content: flex-end;
width: 100%;
padding: 0.55rem 0 0.25rem;
}
/* Die Namenswahl schiebt sich sonst allein nach rechts und lässt den
Filterknopf links stehen */
.rechts > .wahl {
margin-left: 0;
}
.tabs {
gap: 0;
width: 100%;
margin: 0 -0.75rem;
padding: 0 0.75rem;
overflow-x: auto;
scrollbar-width: none;
mask-image: linear-gradient(to right, #000 88%, transparent);
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
padding: 0.6rem 0.65rem 0.55rem;
font-size: 0.9rem;
}
/* Drei Poster nebeneinander, ohne Teaser — mehr Übersicht als zwei
riesige Karten */
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>