Wer einen Film aufs Rad gesetzt hat, kann ihn dort auch wieder runternehmen. Im Raster tragen alle Filme auf dem Rad jetzt eine Marke, nicht nur der eigene Vorschlag — so sieht man auf einen Blick, was schon vorgeschlagen ist. Die Karten hören dafür am Live-Kanal mit. Dazu: der Host löscht einzelne Wertungen, das Cover dockt oben an statt mittig, und die Suche heißt schlicht "Globale Suche". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
633 lines
18 KiB
Vue
633 lines
18 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 MovieDetail from './components/MovieDetail.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('rating')
|
|
const selected = ref(null)
|
|
// Nach dem Neuladen soll derselbe Reiter offen sein
|
|
const TAB_SCHLUESSEL = 'filmabend_tab'
|
|
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', '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 },
|
|
]
|
|
|
|
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)
|
|
}
|
|
// 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: 'wheel', label: 'Rad', count: null },
|
|
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
|
{ id: 'stream', label: 'Stream', count: null },
|
|
])
|
|
|
|
const rad = ref(null)
|
|
|
|
// 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.
|
|
// Ein Name für alles: Vorschläge und Wertungen. Ohne Namen läuft es
|
|
// anonym, das ist ausdrücklich erlaubt.
|
|
const NAME_SCHLUESSEL = 'filmabend_name'
|
|
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
|
|
|
|
watch(name, (wert) => localStorage.setItem(NAME_SCHLUESSEL, wert))
|
|
|
|
const HOST_SCHLUESSEL = 'filmabend_host'
|
|
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
|
|
|
|
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([])
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
const SORTS = {
|
|
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()
|
|
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
|
|
abbestellen = abonnieren((nachricht) => {
|
|
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
|
|
})
|
|
window.addEventListener('keydown', tastatur)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', tastatur)
|
|
abbestellen?.()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].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>
|
|
<input
|
|
v-model.trim="name"
|
|
type="text"
|
|
class="name"
|
|
placeholder="Dein Name"
|
|
maxlength="40"
|
|
/>
|
|
<span v-if="host" class="host">Host</span>
|
|
</nav>
|
|
|
|
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
|
|
|
<WheelTab
|
|
v-else-if="activeTab === 'wheel'"
|
|
ref="rad"
|
|
:host="host"
|
|
@open="selected = $event"
|
|
@reset="meinVorschlag = null"
|
|
/>
|
|
|
|
<WatchedTab
|
|
v-else-if="activeTab === 'watched'"
|
|
:host="host"
|
|
:name="name"
|
|
@open="selected = $event"
|
|
@changed="ladeGesehen"
|
|
/>
|
|
|
|
<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">
|
|
<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)"
|
|
@open="selected = $event"
|
|
/>
|
|
<SeriesCard
|
|
v-else
|
|
:parts="entry.parts"
|
|
:start="entry.best"
|
|
:auf-dem-rad="aufDemRad"
|
|
@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>
|
|
|
|
<MovieDetail
|
|
v-if="selected"
|
|
:movie="selected"
|
|
:series="series"
|
|
@close="selected = null"
|
|
@open="selected = $event"
|
|
:mein-vorschlag="meinVorschlag"
|
|
:host="host"
|
|
:name="name"
|
|
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
|
@suggested="nachVorschlag"
|
|
@watched="ladeGesehen"
|
|
/>
|
|
|
|
</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 {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
margin-bottom: 1.25rem;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.tabs button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.6rem 1rem;
|
|
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);
|
|
}
|
|
|
|
.tabs .name {
|
|
width: 11rem;
|
|
margin: 0 0 0.35rem auto;
|
|
padding: 0.35rem 0.6rem;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.host {
|
|
align-self: center;
|
|
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;
|
|
}
|
|
|
|
.note {
|
|
padding: 2.5rem 0;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
}
|
|
|
|
.note.error {
|
|
color: var(--accent);
|
|
}
|
|
|
|
</style>
|