642 lines
16 KiB
Vue
642 lines
16 KiB
Vue
<script setup>
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
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'
|
|
import VerwaltungTab from './components/VerwaltungTab.vue'
|
|
import WuenscheTab from './components/WuenscheTab.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, host } = useEinstellungen()
|
|
|
|
const {
|
|
runde, ich, michSetzen,
|
|
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
|
|
meinVorschlag, aufDemRad, ladeVorschlag,
|
|
merkliste, ladeMerkliste,
|
|
wuensche, ladeWuensche,
|
|
} = useMeins({ host })
|
|
|
|
const {
|
|
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, 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(() =>
|
|
activeTab.value === 'netflix' ? visible.value : visibleCanon.value
|
|
)
|
|
|
|
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
|
|
liste: aktuelleListe,
|
|
ansicht,
|
|
})
|
|
|
|
const tabs = 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,
|
|
},
|
|
{ id: 'search', label: 'Suche', count: null },
|
|
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
|
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
|
{ 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 },
|
|
...(host.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(() =>
|
|
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
|
|
)
|
|
|
|
// 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 = 'aehnlich'
|
|
}
|
|
|
|
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>
|
|
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
|
|
<span v-else class="host">Host</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
|
|
<SearchTab
|
|
v-if="activeTab === 'search'"
|
|
:gesehen-ids="gesehenIds"
|
|
:ohne-gesehene="ohneGesehene"
|
|
:filter="suchFilter"
|
|
:ansicht="ansicht"
|
|
@open="selected = $event"
|
|
/>
|
|
|
|
<AehnlichTab
|
|
v-else
|
|
:gesehen-ids="gesehenIds"
|
|
:filter="suchFilter"
|
|
:ansicht="ansicht"
|
|
:start="aehnlichStart"
|
|
@open="filmOeffnen"
|
|
@gestartet="aehnlichStart = null"
|
|
/>
|
|
|
|
<FilterSidebar
|
|
v-model:sort="suchSort"
|
|
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="true"
|
|
@toggle="toggleGenre"
|
|
@all="toggleAll"
|
|
@reset="resetGenres"
|
|
/>
|
|
</div>
|
|
|
|
<WheelTab
|
|
v-else-if="activeTab === 'wheel'"
|
|
ref="rad"
|
|
:host="host"
|
|
:merkliste="merkliste"
|
|
:mine="meinVorschlag?.id ?? null"
|
|
:gesehen-ids="gesehenIds"
|
|
@open="selected = $event"
|
|
@reset="meinVorschlag = null"
|
|
/>
|
|
|
|
<WatchedTab
|
|
v-else-if="activeTab === 'watched'"
|
|
:host="host"
|
|
@open="selected = $event"
|
|
@changed="ladeGesehen"
|
|
/>
|
|
|
|
<MerklisteTab
|
|
v-else-if="activeTab === 'merkliste'"
|
|
:gesehen-ids="gesehenIds"
|
|
:items="merkliste"
|
|
:ich="ich"
|
|
@open="selected = $event"
|
|
@changed="ladeMerkliste"
|
|
/>
|
|
|
|
<WuenscheTab
|
|
v-else-if="activeTab === 'wuensche'"
|
|
:items="wuensche"
|
|
:ich="ich"
|
|
:host="host"
|
|
@changed="ladeWuensche"
|
|
/>
|
|
|
|
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" />
|
|
|
|
<StreamTab v-else-if="activeTab === 'stream'" />
|
|
|
|
<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: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"
|
|
@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>
|
|
|
|
<MovieDetail
|
|
v-if="selected"
|
|
:movie="selected"
|
|
:series="series"
|
|
:mein-vorschlag="meinVorschlag"
|
|
:host="host"
|
|
: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"
|
|
/>
|
|
</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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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>
|