+
-
+
@@ -546,7 +200,8 @@ async function oeffnen(item) {
.fenster {
position: relative;
align-self: stretch;
- height: 370px;
+ /* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */
+ height: calc(var(--kachel) * 1.5 + 40px);
overflow: hidden;
/* Links und rechts läuft der Streifen ins Dunkel aus */
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
@@ -557,7 +212,7 @@ async function oeffnen(item) {
top: 20px;
left: 50%;
display: flex;
- gap: 10px;
+ gap: var(--luecke);
will-change: transform;
/* Die Kurve setzt das Skript je Phase */
transition-property: transform;
@@ -565,8 +220,8 @@ async function oeffnen(item) {
.kachel {
position: relative;
- flex: 0 0 220px;
- height: 330px;
+ flex: 0 0 var(--kachel);
+ height: calc(var(--kachel) * 1.5);
overflow: hidden;
background: var(--bg-soft);
border: 2px solid;
@@ -634,6 +289,28 @@ async function oeffnen(item) {
pointer-events: none;
}
+.marke::before,
+.marke::after {
+ position: absolute;
+ left: 50%;
+ width: 0;
+ height: 0;
+ margin-left: -9px;
+ content: '';
+ border-right: 9px solid transparent;
+ border-left: 9px solid transparent;
+}
+
+.marke::before {
+ top: 0;
+ border-top: 14px solid var(--accent);
+}
+
+.marke::after {
+ bottom: 0;
+ border-bottom: 14px solid var(--accent);
+}
+
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
.marke.anlauf {
animation: puls 0.33s ease-in-out 3;
@@ -667,28 +344,6 @@ async function oeffnen(item) {
}
}
-.marke::before,
-.marke::after {
- position: absolute;
- left: 50%;
- width: 0;
- height: 0;
- margin-left: -9px;
- content: '';
- border-right: 9px solid transparent;
- border-left: 9px solid transparent;
-}
-
-.marke::before {
- top: 0;
- border-top: 14px solid var(--accent);
-}
-
-.marke::after {
- bottom: 0;
- border-bottom: 14px solid var(--accent);
-}
-
.leer-bahn {
position: absolute;
inset: 0;
@@ -702,6 +357,21 @@ async function oeffnen(item) {
gap: 0.7rem;
}
+.dreh {
+ padding: 0.7rem 2.2rem;
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 600;
+ background: var(--accent);
+ border-radius: 8px;
+}
+
+.dreh:disabled {
+ background: var(--line);
+ color: var(--muted);
+ cursor: default;
+}
+
.reset {
padding: 0.7rem 1.4rem;
color: var(--muted);
@@ -721,73 +391,6 @@ async function oeffnen(item) {
cursor: default;
}
-.leer {
- color: var(--muted);
- font-size: 0.85rem;
-}
-
-.dreh {
- padding: 0.7rem 2.2rem;
- color: #fff;
- font-size: 1rem;
- font-weight: 600;
- background: var(--accent);
- border-radius: 8px;
-}
-
-.dreh:disabled {
- background: var(--line);
- color: var(--muted);
- cursor: default;
-}
-
-.sammlung-wuerfeln {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- margin-bottom: 1.6rem;
-}
-
-.schalter {
- display: flex;
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 8px;
-}
-
-.schalter button {
- flex: 1;
- padding: 0.45rem 0.9rem;
- color: var(--muted);
- font-size: 0.85rem;
- font-weight: 600;
- background: var(--bg);
-}
-
-.schalter button.an {
- color: var(--text);
- background: var(--bg-soft);
-}
-
-.wuerfeln {
- padding: 0.5rem 1.1rem;
- font-size: 0.88rem;
- font-weight: 600;
- background: var(--bg);
- border: 1px solid var(--line);
- border-radius: 8px;
-}
-
-.wuerfeln:hover:not(:disabled) {
- border-color: var(--accent);
-}
-
-.wuerfeln:disabled {
- color: var(--muted);
- cursor: not-allowed;
- opacity: 0.5;
-}
-
.gewinner {
display: flex;
gap: 1rem;
@@ -837,111 +440,6 @@ async function oeffnen(item) {
font-size: 0.85rem;
}
-.liste h3 {
- margin-bottom: 0.7rem;
- color: var(--muted);
- font-size: 0.78rem;
- font-weight: 600;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.anzahl {
- padding: 0.1rem 0.45rem;
- background: var(--bg-soft);
- border: 1px solid var(--line);
- border-radius: 20px;
-}
-
-.liste ul {
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
- list-style: none;
-}
-
-.eintrag {
- display: flex;
- gap: 0.7rem;
- align-items: flex-start;
- width: 100%;
- padding: 0.55rem;
- color: var(--text);
- font-size: 0.86rem;
- text-align: left;
- border-radius: 8px;
-}
-
-/* Schon gelaufen — die Ziehung überspringt diesen Film */
-.eintrag.gesehen {
- opacity: 0.45;
- filter: grayscale(1);
-}
-
-.eintrag.gesehen:hover {
- opacity: 1;
- filter: none;
-}
-
-.eintrag:hover {
- background: var(--bg-soft);
-}
-
-/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
-.cover {
- display: block;
- width: 46px;
- flex-shrink: 0;
- overflow: hidden;
- background: var(--bg-soft);
- border-left: 3px solid;
- border-radius: 3px;
-}
-
-.cover img {
- display: block;
- width: 100%;
- aspect-ratio: 2 / 3;
- object-fit: cover;
-}
-
-.daten {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 0.15rem;
- padding-top: 0.1rem;
-}
-
-.titel {
- overflow: hidden;
- font-weight: 600;
- line-height: 1.25;
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.eintrag.eigen .titel {
- color: var(--accent);
-}
-
-.meta,
-.genres,
-.von {
- overflow: hidden;
- color: var(--muted);
- font-size: 0.74rem;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.von {
- margin-top: 0.15rem;
- font-size: 0.72rem;
- opacity: 0.75;
-}
-
.note {
padding: 3rem 0;
color: var(--muted);
diff --git a/frontend/src/components/WuenscheTab.vue b/frontend/src/components/WuenscheTab.vue
index 06e87c2..d577aa6 100644
--- a/frontend/src/components/WuenscheTab.vue
+++ b/frontend/src/components/WuenscheTab.vue
@@ -504,6 +504,12 @@ h3 {
opacity: 1;
}
+@media (hover: none) {
+ .aktionen {
+ opacity: 1;
+ }
+}
+
.weg {
padding: 0 0.25rem;
color: var(--muted);
diff --git a/frontend/src/composables/useEinstellungen.js b/frontend/src/composables/useEinstellungen.js
new file mode 100644
index 0000000..6213147
--- /dev/null
+++ b/frontend/src/composables/useEinstellungen.js
@@ -0,0 +1,58 @@
+// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht,
+// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage,
+// alles je Browser.
+import { onMounted, onUnmounted, ref, watch } from 'vue'
+
+const TAB_IDS = [
+ 'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
+ 'wuensche', 'verwaltung', 'stream',
+]
+
+function gemerkt(schluessel, standard) {
+ const wert = ref(localStorage.getItem(schluessel) ?? standard)
+ watch(wert, (neu) => localStorage.setItem(schluessel, neu))
+ return wert
+}
+
+export function useEinstellungen() {
+ // Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
+ const ansicht = gemerkt('filmabend_ansicht', 'kompakt')
+
+ // Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
+ // Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
+ const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
+ watch(ohneGesehene, (an) =>
+ localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
+ )
+
+ // Nach dem Neuladen soll derselbe Reiter offen sein
+ const zuletzt = localStorage.getItem('filmabend_tab')
+ const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
+ watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
+
+ // Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login —
+ // die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
+ const host = ref(localStorage.getItem('filmabend_host') === '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) => {
+ 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('filmabend_host', host.value ? '1' : '0')
+ }
+ }
+
+ onMounted(() => window.addEventListener('keydown', tastatur))
+ onUnmounted(() => window.removeEventListener('keydown', tastatur))
+
+ return { ansicht, ohneGesehene, activeTab, host }
+}
diff --git a/frontend/src/composables/useFilter.js b/frontend/src/composables/useFilter.js
new file mode 100644
index 0000000..e1b62f5
--- /dev/null
+++ b/frontend/src/composables/useFilter.js
@@ -0,0 +1,179 @@
+// Die Filterleiste: Genres, Regler, Sortierung, Suche — und die Prädikate,
+// mit denen der Katalog danach gesiebt wird.
+import { computed, ref, watch } from 'vue'
+
+// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
+// sonst wandern die Enden bei jeder Auswahl
+export 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 }))
+
+export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) {
+ 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')
+ const include = ref([])
+ const exclude = ref([])
+
+ const suchReiter = () => activeTab.value === 'search' || activeTab.value === 'aehnlich'
+ const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value)
+
+ const grenzen = computed(() => {
+ if (suchReiter()) return SUCH_GRENZEN
+ 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({})
+
+ function resetRanges() {
+ ranges.value = Object.fromEntries(
+ Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
+ )
+ }
+
+ // Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
+ watch(grenzen, resetRanges, { immediate: true })
+
+ 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
+ }
+
+ // Nur Genres anbieten, die im offenen Reiter auch Treffer haben
+ const genreOptions = computed(() => {
+ if (suchReiter()) return SUCH_GENRES
+ 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 }))
+ })
+
+ 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()
+ }
+
+ // 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 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))
+ }
+
+ function nichtGesehen(movie) {
+ return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
+ }
+
+ function passt(movie) {
+ return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
+ }
+
+ // 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,
+ }
+ })
+
+ return {
+ search,
+ sort,
+ suchSort,
+ include,
+ exclude,
+ grenzen,
+ ranges,
+ genreOptions,
+ toggleGenre,
+ toggleAll,
+ resetGenres,
+ nichtGesehen,
+ passt,
+ suchFilter,
+ }
+}
diff --git a/frontend/src/composables/useKatalog.js b/frontend/src/composables/useKatalog.js
new file mode 100644
index 0000000..1605681
--- /dev/null
+++ b/frontend/src/composables/useKatalog.js
@@ -0,0 +1,150 @@
+// Der Katalog: alle Filme aus der Datenbank, aufgeteilt in Netflix und
+// Kanon, zu Reihen gruppiert und sortierbar.
+import { computed, ref } from 'vue'
+
+// 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
+
+// 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)
+}
+
+// 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 }
+}
+
+export function matches(movie, term) {
+ return (
+ movie.title.toLowerCase().includes(term) ||
+ (movie.original_title || '').toLowerCase().includes(term)
+ )
+}
+
+export function useKatalog() {
+ const movies = ref([])
+ const status = ref(null)
+ const loading = ref(true)
+ const error = ref('')
+
+ 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
+ }
+ }
+
+ const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
+ const canonMovies = computed(() =>
+ movies.value.filter((m) => m.canon_group !== null)
+ )
+
+ 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 = {
+ 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 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 })),
+ ]
+ })
+
+ return {
+ movies,
+ status,
+ loading,
+ error,
+ load,
+ netflixMovies,
+ canonMovies,
+ canonGroups,
+ netflixEntries,
+ SORTS,
+ }
+}
diff --git a/frontend/src/composables/useLangeListe.js b/frontend/src/composables/useLangeListe.js
new file mode 100644
index 0000000..08702de
--- /dev/null
+++ b/frontend/src/composables/useLangeListe.js
@@ -0,0 +1,81 @@
+// Eine Liste mit tausenden Karten: gerendert wird nur der Anfang, der Rest
+// kommt beim Scrollen nach. Dazu der Knopf, der wieder nach oben bringt.
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
+
+// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
+// Bilder — der Browser meldet dann "Seite reagiert nicht".
+const STAPEL = 100
+
+// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
+// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
+const OBEN_DAUER = 450
+
+export function useLangeListe({ liste, ansicht }) {
+ const gezeigt = ref(STAPEL)
+ const sichtbar = computed(() => liste.value.slice(0, gezeigt.value))
+
+ // Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
+ watch([liste, ansicht], () => {
+ gezeigt.value = STAPEL
+ })
+
+ // Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
+ // Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
+ const nachschub = ref(null)
+ const beobachter = new IntersectionObserver(
+ (eintraege) => {
+ if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
+ },
+ // Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
+ { rootMargin: '600px' }
+ )
+
+ async function mehrLaden() {
+ if (gezeigt.value >= liste.value.length) return
+ gezeigt.value += STAPEL
+ // Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
+ // der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
+ // käme sonst nie ein zweiter Stapel.
+ await nextTick()
+ const el = nachschub.value
+ if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
+ }
+
+ // Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
+ // Wechsel neu anmelden
+ watch(nachschub, (el, vorher) => {
+ if (vorher) beobachter.unobserve(vorher)
+ if (el) beobachter.observe(el)
+ })
+
+ // Bei 2500 Karten kommt man sonst nicht mehr nach oben
+ const obenKnopf = ref(false)
+
+ function beimScrollen() {
+ obenKnopf.value = window.scrollY > 800
+ }
+
+ 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)
+ }
+
+ onMounted(() => window.addEventListener('scroll', beimScrollen, { passive: true }))
+
+ onUnmounted(() => {
+ window.removeEventListener('scroll', beimScrollen)
+ beobachter.disconnect()
+ })
+
+ return { gezeigt, sichtbar, nachschub, obenKnopf, nachOben }
+}
diff --git a/frontend/src/composables/useMeins.js b/frontend/src/composables/useMeins.js
new file mode 100644
index 0000000..4e92e05
--- /dev/null
+++ b/frontend/src/composables/useMeins.js
@@ -0,0 +1,124 @@
+// Wer ich bin und was an mir hängt: die Runde, meine Sammlung, mein
+// Vorschlag, die gesehenen Filme und die Wünsche. Alles wird beim Start
+// geladen und über den Live-Kanal frisch gehalten.
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { abonnieren } from '../live'
+
+async function hole(url) {
+ try {
+ const resp = await fetch(url)
+ return resp.ok ? await resp.json() : null
+ } catch {
+ return null
+ }
+}
+
+export function useMeins({ host }) {
+ // 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 ladeRunde() {
+ const daten = await hole('/api/users')
+ if (!daten) return
+ runde.value = daten.items
+ ich.value = daten.mine
+ }
+
+ async function michSetzen(id) {
+ await fetch('/api/users/waehlen', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: id ?? null }),
+ })
+ // Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
+ // Nachladen zeigt die Seite weiter die des vorigen Benutzers.
+ await ladeRunde()
+ ladeVorschlag()
+ ladeMerkliste()
+ ladeGesehen()
+ ladeWuensche()
+ }
+
+ // Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
+ // ob ein Film schon lief
+ const gesehen = ref([])
+
+ async function ladeGesehen() {
+ gesehen.value = (await hole('/api/watched')) ?? gesehen.value
+ }
+
+ // Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
+ const gesehenIds = computed(() => new Set(gesehen.value.map((e) => e.movie_id)))
+
+ // Ausgeblendete Filme zählen für Gäste nicht mit
+ const gesehenZahl = computed(
+ () => gesehen.value.filter((e) => host.value || !e.hidden).length
+ )
+
+ // Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen —
+ // und alle Filme auf der Bahn, damit die Karten es zeigen können
+ const meinVorschlag = ref(null)
+ const aufDemRad = ref([])
+
+ async function ladeVorschlag() {
+ const daten = await hole('/api/suggestions')
+ if (!daten) {
+ meinVorschlag.value = null
+ aufDemRad.value = []
+ return
+ }
+ meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
+ aufDemRad.value = daten.items.map((i) => i.movie_id)
+ }
+
+ // Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
+ const merkliste = ref([])
+
+ async function ladeMerkliste() {
+ merkliste.value = (await hole('/api/wishlist')) ?? merkliste.value
+ }
+
+ // Was der Seite noch fehlt
+ const wuensche = ref([])
+
+ async function ladeWuensche() {
+ wuensche.value = (await hole('/api/features')) ?? wuensche.value
+ }
+
+ let abbestellen = null
+
+ onMounted(() => {
+ 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()
+ })
+ })
+
+ onUnmounted(() => abbestellen?.())
+
+ return {
+ runde,
+ ich,
+ michSetzen,
+ gesehen,
+ gesehenIds,
+ gesehenZahl,
+ ladeGesehen,
+ meinVorschlag,
+ aufDemRad,
+ ladeVorschlag,
+ merkliste,
+ ladeMerkliste,
+ wuensche,
+ ladeWuensche,
+ }
+}
diff --git a/frontend/src/composables/useZiehung.js b/frontend/src/composables/useZiehung.js
new file mode 100644
index 0000000..1fb6974
--- /dev/null
+++ b/frontend/src/composables/useZiehung.js
@@ -0,0 +1,287 @@
+// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus Postern
+// rast unter der Marke durch und bremst auf dem Gewinner aus.
+//
+// Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn rast los und kriecht am
+// Ende). Jedes zweite Mal bricht danach die Halterung: kurze Ruhe, dann
+// rollt die Bahn ein paar Poster zurück. Der Server nennt die Gesamtdauer
+// und die Halte, die Phasenlängen stehen hier — Ruhe plus Rückfahrt müssen
+// zu BRUCH_DAUER im Backend passen.
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
+import { anlaufTon, fanfare, knacken, tick } from '../klang'
+
+const ANLAUF = 1.0
+const BRUCH_RUHE = 1.2
+const BRUCH_FAHRT = 2.5
+// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
+const ENTHUELLUNG = 0.6
+
+// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
+const KURVEN = {
+ fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
+ bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
+}
+
+// Auf dem Handy sind die Poster kleiner, sonst passen keine drei ins Bild
+const MASSE = {
+ breit: { kachel: 220, luecke: 10 },
+ schmal: { kachel: 140, luecke: 8 },
+}
+const SCHMAL = '(max-width: 640px)'
+
+// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
+// verschwimmt die Marke mit dem Rand
+export const FARBEN = [
+ '#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
+ '#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
+]
+
+export function useZiehung({ items }) {
+ const schmal = ref(window.matchMedia(SCHMAL).matches)
+ const masse = computed(() => {
+ const m = schmal.value ? MASSE.schmal : MASSE.breit
+ return { ...m, schritt: m.kachel + m.luecke }
+ })
+
+ const ergebnis = ref(null)
+ const dreht = ref(false)
+ const gewinner = ref(null)
+ const dauer = ref(0)
+ const x = ref(0)
+ const anlauf = ref(false)
+ const bruch = ref(false)
+ const kurve = ref(KURVEN.fahrt)
+ const bahn = ref(null)
+ let timer = null
+ let enthuellung = null
+
+ // Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
+ // Zielkachel unter der Marke, egal wie breit das Fenster ist.
+ function stoppX(stopp) {
+ const { kachel, schritt } = masse.value
+ return -(stopp.position * schritt + kachel / 2 + stopp.versatz * kachel)
+ }
+
+ // Vor der ersten Ziehung stehen die Vorschläge einfach in einer Reihe
+ function ruheX() {
+ const { luecke, schritt } = masse.value
+ return -(items.value.length * schritt - luecke) / 2
+ }
+
+ function fahren(sekunden, k, ziel) {
+ kurve.value = k
+ dauer.value = sekunden
+ x.value = ziel
+ }
+
+ function ruhen() {
+ if (!ergebnis.value) fahren(0, KURVEN.fahrt, ruheX())
+ }
+
+ // Liest die laufende Position aus dem Übergang und tickt bei jedem
+ // Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
+ let zuletzt = -1
+ let letzteX = 0
+ let letzteZeit = 0
+
+ function ticken(zeit) {
+ if (!dreht.value || !bahn.value) return
+ const { luecke, schritt } = masse.value
+ const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
+ const idx = Math.floor((-m.m41 + luecke / 2) / schritt)
+ const dt = (zeit - letzteZeit) / 1000
+ const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
+ letzteX = m.m41
+ letzteZeit = zeit
+ if (zuletzt === -1) {
+ // Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
+ zuletzt = idx
+ } else if (idx !== zuletzt) {
+ zuletzt = idx
+ tick(tempo)
+ }
+ requestAnimationFrame(ticken)
+ }
+
+ function ticksStarten() {
+ zuletzt = -1
+ letzteZeit = 0
+ requestAnimationFrame(ticken)
+ }
+
+ // Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
+ // hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
+ function phasen(erg) {
+ const erst = erg.stopps[0]
+ const ende = erg.stopps.at(-1)
+ const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
+ const fahrt = erg.dauer - ANLAUF - extra
+ const liste = [
+ {
+ d: ANLAUF,
+ start(rest) {
+ anlauf.value = true
+ if (rest > 0) anlaufTon(rest)
+ },
+ ende() {
+ anlauf.value = false
+ },
+ },
+ {
+ d: fahrt,
+ start(rest) {
+ fahren(rest, KURVEN.fahrt, stoppX(erst))
+ ticksStarten()
+ },
+ },
+ ]
+ if (erg.stopps.length > 1) {
+ liste.push(
+ { d: BRUCH_RUHE },
+ {
+ d: BRUCH_FAHRT,
+ start(rest) {
+ bruch.value = true
+ if (rest > 0) knacken()
+ fahren(rest, KURVEN.bruch, stoppX(ende))
+ },
+ ende() {
+ bruch.value = false
+ },
+ }
+ )
+ }
+ return liste
+ }
+
+ function ablauf(erg, liste, i, uebersprungen) {
+ if (i >= liste.length) {
+ dreht.value = false
+ // Erst Stille, dann der Glow
+ enthuellung = window.setTimeout(() => {
+ gewinner.value = erg.gewinner
+ fanfare()
+ }, ENTHUELLUNG * 1000)
+ return
+ }
+ const ph = liste[i]
+ if (uebersprungen >= ph.d) {
+ // Schon vorbei: nur den Endzustand setzen
+ ph.start?.(0)
+ ph.ende?.()
+ ablauf(erg, liste, i + 1, uebersprungen - ph.d)
+ return
+ }
+ const rest = ph.d - uebersprungen
+ ph.start?.(rest)
+ timer = window.setTimeout(() => {
+ ph.ende?.()
+ ablauf(erg, liste, i + 1, 0)
+ }, rest * 1000)
+ }
+
+ function abbrechen() {
+ clearTimeout(timer)
+ clearTimeout(enthuellung)
+ }
+
+ async function abspielen(erg, uebersprungen = 0) {
+ abbrechen()
+ gewinner.value = null
+ ergebnis.value = erg
+ dreht.value = true
+
+ // Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
+ // Ergebnis aus los
+ fahren(0, KURVEN.fahrt, -3 * masse.value.schritt)
+ await nextTick()
+ void bahn.value?.offsetWidth
+
+ ablauf(erg, phasen(erg), 0, uebersprungen)
+ }
+
+ async function laufendenDrehHolen() {
+ try {
+ const resp = await fetch('/api/spin')
+ if (!resp.ok) return
+ const daten = await resp.json()
+ if (!daten.result) return
+ if (daten.running) {
+ abspielen(daten.result, daten.elapsed)
+ } else {
+ // Ziehung ist durch: Bahn ohne Animation auf die Endstellung setzen,
+ // sonst steht die Marke woanders als die Gewinnerkarte
+ ergebnis.value = daten.result
+ fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
+ gewinner.value = daten.result.gewinner
+ }
+ } catch {
+ /* dann eben ohne */
+ }
+ }
+
+ function zurueckgesetzt() {
+ abbrechen()
+ anlauf.value = false
+ bruch.value = false
+ ergebnis.value = null
+ gewinner.value = null
+ dreht.value = false
+ }
+
+ // Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
+ const kacheln = computed(() => {
+ const erg = ergebnis.value
+ if (erg) {
+ const ziel = erg.stopps.at(-1).position
+ return erg.streifen.map((i, k) => ({
+ key: k,
+ film: erg.karten[i],
+ farbe: FARBEN[i % FARBEN.length],
+ treffer: k === ziel,
+ }))
+ }
+ return items.value.map((item, i) => ({
+ key: i,
+ film: item,
+ farbe: FARBEN[i % FARBEN.length],
+ treffer: false,
+ }))
+ })
+
+ // Dreht jemand das Handy, ändern sich die Maße — die Bahn rückt nach
+ const medien = window.matchMedia(SCHMAL)
+ const beiWechsel = (e) => {
+ schmal.value = e.matches
+ }
+
+ watch(schmal, () => {
+ if (dreht.value) return
+ if (ergebnis.value) fahren(0, KURVEN.fahrt, stoppX(ergebnis.value.stopps.at(-1)))
+ else ruhen()
+ })
+
+ onMounted(() => medien.addEventListener('change', beiWechsel))
+
+ onUnmounted(() => {
+ abbrechen()
+ medien.removeEventListener('change', beiWechsel)
+ })
+
+ return {
+ masse,
+ ergebnis,
+ dreht,
+ gewinner,
+ dauer,
+ x,
+ anlauf,
+ bruch,
+ kurve,
+ bahn,
+ kacheln,
+ ruhen,
+ abspielen,
+ laufendenDrehHolen,
+ zurueckgesetzt,
+ }
+}
diff --git a/frontend/src/klang.js b/frontend/src/klang.js
new file mode 100644
index 0000000..862bb46
--- /dev/null
+++ b/frontend/src/klang.js
@@ -0,0 +1,50 @@
+// Die Geräusche der Ziehung: ein Tick je Poster, das unter der Marke
+// durchläuft, ein Aufladen im Anlauf, ein Knacken beim Bruch und ein Klang
+// zur Enthüllung. Alles synthetisch, keine Dateien.
+//
+// Der Browser gibt Ton erst nach einer Eingabe frei — wer nur zuschaut und
+// nie geklickt hat, hört nichts, sieht aber alles.
+
+let audio = null
+
+export function klang() {
+ if (!audio) audio = new AudioContext()
+ if (audio.state === 'suspended') audio.resume()
+ return audio
+}
+
+function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
+ const a = klang()
+ const t = a.currentTime + start
+ const osc = a.createOscillator()
+ const gain = a.createGain()
+ osc.type = art
+ osc.frequency.value = frequenz
+ if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
+ gain.gain.setValueAtTime(lautstaerke, t)
+ gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
+ osc.connect(gain).connect(a.destination)
+ osc.start(t)
+ osc.stop(t + dauer)
+}
+
+// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
+// man hört das Bremsen.
+export function tick(tempo) {
+ const a = Math.min(1, tempo / 3000)
+ ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
+}
+
+export function anlaufTon(sekunden) {
+ ton(150, sekunden, 0.08, 'sine', 0, 520)
+}
+
+export function knacken() {
+ ton(120, 0.12, 0.25, 'square')
+ ton(80, 0.3, 0.2, 'sawtooth', 0.08)
+}
+
+export function fanfare() {
+ ton(660, 0.5, 0.12, 'sine')
+ ton(990, 0.7, 0.1, 'sine', 0.12)
+}