Files
horror/frontend/src/App.vue
2026-09-08 17:57:51 +02:00

899 lines
26 KiB
Vue

<script setup>
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'
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'
const movies = ref([])
const status = ref(null)
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'
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', 'aehnlich', 'wheel', 'merkliste', 'watched',
'wuensche', 'verwaltung', 'stream',
]
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
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)
)
// 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 },
]
// 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) {
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(() => {
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) {
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
}
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
return [...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
.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) {
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
byGroup.get(movie.canon_group).push(movie)
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.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: '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 }]),
{ 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.length },
...(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
const obenKnopf = ref(false)
function beimScrollen() {
obenKnopf.value = window.scrollY > 800
}
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
const OBEN_DAUER = 450
function nachOben() {
const start = window.scrollY
const beginn = performance.now()
function schritt(jetzt) {
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
// Sanft an- und auslaufend
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
window.scrollTo(0, start * (1 - p))
if (t < 1) requestAnimationFrame(schritt)
}
requestAnimationFrame(schritt)
}
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
// ob der Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
try {
const resp = await fetch('/api/watched')
if (resp.ok) gesehen.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(
() => gesehen.value.filter((e) => host.value || !e.hidden).length
)
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus.
// Den Namen setzt danach der Server an Vorschläge und Wertungen.
const runde = ref([])
const ich = ref(null)
async function michSetzen(id) {
await fetch('/api/users/waehlen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
})
ladeRunde()
}
async function ladeRunde() {
try {
const resp = await fetch('/api/users')
if (!resp.ok) return
const daten = await resp.json()
runde.value = daten.items
ich.value = daten.mine
} catch {
/* dann eben ohne */
}
}
const HOST_SCHLUESSEL = 'filmabend_host'
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
watch(host, (an) => {
// Verwaltung sieht nur der Host, Vorschläge nur die Gäste
const weg = an ? 'merkliste' : 'verwaltung'
if (activeTab.value === weg) activeTab.value = 'netflix'
})
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
}
}
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null)
// Alle Filme auf dem Rad, damit die Karten es zeigen können
const aufDemRad = ref([])
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
const merkliste = ref([])
// Was der Seite noch fehlt
const wuensche = ref([])
async function ladeWuensche() {
try {
const resp = await fetch('/api/features')
if (resp.ok) wuensche.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
async function ladeMerkliste() {
try {
const resp = await fetch('/api/wishlist')
if (resp.ok) merkliste.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
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
aufDemRad.value = daten.items.map((i) => i.movie_id)
} catch {
meinVorschlag.value = null
aufDemRad.value = []
}
}
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
const average = computed(() => {
const rated = movies.value.filter((m) => m.vote_count)
if (!rated.length) return 0
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
})
function score(movie) {
const votes = movie.vote_count || 0
return (
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
)
}
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
// Karten bei jedem Filtern neu durcheinander.
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) => score(b) - score(a),
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 matches(movie, term) {
return (
movie.title.toLowerCase().includes(term) ||
(movie.original_title || '').toLowerCase().includes(term)
)
}
// 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(() =>
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.
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 || '')
)
})
const currentCount = computed(() =>
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
)
async function load() {
loading.value = true
error.value = ''
try {
const [moviesRes, statusRes] = await Promise.all([
fetch('/api/movies'),
fetch('/api/status'),
])
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
movies.value = await moviesRes.json()
status.value = await statusRes.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
let abbestellen = null
onMounted(() => {
load()
ladeVorschlag()
ladeGesehen()
ladeRunde()
ladeMerkliste()
ladeWuensche()
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
else if (nachricht.type === 'users') ladeRunde()
else if (nachricht.type === 'features') ladeWuensche()
})
window.addEventListener('keydown', tastatur)
window.addEventListener('scroll', beimScrollen, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
window.removeEventListener('scroll', beimScrollen)
abbestellen?.()
})
</script>
<template>
<div class="page" :class="{
breit: [
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
].includes(activeTab),
}">
<nav 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>
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
<span v-else class="host">Host</span>
</nav>
<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'"
ref="rad"
:host="host"
@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'"
: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="!currentCount" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p>
<div class="grid" :class="ansicht">
<template
v-for="entry in activeTab === 'netflix' ? visible : visibleCanon"
:key="entry.group"
>
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht"
@open="filmOeffnen"
/>
<SeriesCard
v-else
:parts="entry.parts"
:start="entry.best"
:auf-dem-rad="aufDemRad"
:ansicht="ansicht"
@open="filmOeffnen"
/>
</template>
</div>
</div>
<FilterSidebar
v-if="genreOptions.length"
v-model:search="search"
v-model:sort="sort"
v-model:ansicht="ansicht"
v-model:ranges="ranges"
: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>
<MovieDetail
v-if="selected"
:movie="selected"
:series="series"
@close="selected = null"
@open="filmOeffnen"
: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)"
@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);
}
/* Such- und Rad-Reiter haben keine Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
.tabs {
position: sticky;
top: 0;
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 1.25rem;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.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;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.host {
align-self: center;
margin-left: 0.4rem;
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);
}
@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));
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);
}
</style>