update
This commit is contained in:
239
frontend/src/components/FilterSidebar.vue
Normal file
239
frontend/src/components/FilterSidebar.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import RangeFilter from './RangeFilter.vue'
|
||||
|
||||
const props = defineProps({
|
||||
options: { type: Array, required: true },
|
||||
include: { type: Array, required: true },
|
||||
exclude: { type: Array, required: true },
|
||||
regler: { type: Array, default: () => [] },
|
||||
grenzen: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
|
||||
const search = defineModel('search', { type: String, default: '' })
|
||||
const sort = defineModel('sort', { type: String, default: 'rating' })
|
||||
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||
|
||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||
const etwasAktiv = computed(
|
||||
() =>
|
||||
props.include.length > 0 ||
|
||||
props.exclude.length > 0 ||
|
||||
props.regler.some((r) => {
|
||||
const spanne = ranges.value[r.key]
|
||||
const grenze = props.grenzen[r.key]
|
||||
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
||||
})
|
||||
)
|
||||
|
||||
function beschriften(regler) {
|
||||
return (wert) => {
|
||||
// Jahreszahlen ohne Tausenderpunkt — sonst steht dort "2.026"
|
||||
const zahl = regler.nachkomma
|
||||
? wert.toFixed(regler.nachkomma)
|
||||
: regler.roh
|
||||
? String(wert)
|
||||
: wert.toLocaleString('de-DE')
|
||||
return zahl + (regler.einheit || '')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="controls">
|
||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<select v-model="sort">
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="head">
|
||||
<h3>Filter</h3>
|
||||
<button v-if="etwasAktiv" type="button" class="reset" @click="emit('reset')">
|
||||
zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="regler.length" class="regler-block">
|
||||
<RangeFilter
|
||||
v-for="eintrag in regler"
|
||||
:key="eintrag.key"
|
||||
:model-value="ranges[eintrag.key] || [0, 0]"
|
||||
:label="eintrag.label"
|
||||
:min="grenzen[eintrag.key]?.min ?? 0"
|
||||
:max="grenzen[eintrag.key]?.max ?? 0"
|
||||
:step="eintrag.step"
|
||||
:gestaucht="!!eintrag.gestaucht"
|
||||
:format="beschriften(eintrag)"
|
||||
@update:model-value="ranges = { ...ranges, [eintrag.key]: $event }"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Genres zeigen</h4>
|
||||
<div class="badges">
|
||||
<button
|
||||
type="button"
|
||||
class="badge alle"
|
||||
:class="{ on: include.length === options.length }"
|
||||
@click="emit('all', 'include')"
|
||||
>
|
||||
alle
|
||||
</button>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="'in-' + option.genre"
|
||||
type="button"
|
||||
class="badge"
|
||||
:class="{ on: include.includes(option.genre) }"
|
||||
@click="emit('toggle', 'include', option.genre)"
|
||||
>
|
||||
{{ option.genre }}
|
||||
<span class="count">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Genres ausblenden</h4>
|
||||
<div class="badges">
|
||||
<button
|
||||
type="button"
|
||||
class="badge alle"
|
||||
:class="{ on: exclude.length === options.length }"
|
||||
@click="emit('all', 'exclude')"
|
||||
>
|
||||
alle
|
||||
</button>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="'out-' + option.genre"
|
||||
type="button"
|
||||
class="badge out"
|
||||
:class="{ on: exclude.includes(option.genre) }"
|
||||
@click="emit('toggle', 'exclude', option.genre)"
|
||||
>
|
||||
{{ option.genre }}
|
||||
<span class="count">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1rem 0.9rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding-bottom: 1.1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.controls input,
|
||||
.controls select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.regler-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding-bottom: 1.1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reset {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reset:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.badge.alle {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.badge.on {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.out.on {
|
||||
background: #4a4a56;
|
||||
border-color: #4a4a56;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,12 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const rating = computed(() =>
|
||||
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
|
||||
)
|
||||
|
||||
const meta = computed(() =>
|
||||
[props.movie.year || 'Jahr unbekannt', props.movie.runtime && `${props.movie.runtime} Min.`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,7 +29,7 @@ const rating = computed(() =>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<p class="meta">{{ movie.year || 'Jahr unbekannt' }}</p>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -87,6 +93,14 @@ const rating = computed(() =>
|
||||
}
|
||||
|
||||
h2 {
|
||||
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
|
||||
unterschiedlich weit nach unten */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
min-height: calc(3 * 1.3em);
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@@ -102,6 +116,7 @@ h2 {
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
min-height: calc(4 * 1.45em);
|
||||
color: #b9b9c6;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
series: { type: Array, default: () => [] },
|
||||
meinVorschlag: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open'])
|
||||
const emit = defineEmits(['close', 'open', 'suggested'])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit', name: '' })
|
||||
|
||||
const schonVorgeschlagen = computed(
|
||||
() => props.meinVorschlag?.movie_id === props.movie.id
|
||||
)
|
||||
|
||||
async function abschicken() {
|
||||
vorschlagen.value = { ...vorschlagen.value, zustand: 'laeuft' }
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
movie_id: props.movie.id,
|
||||
name: vorschlagen.value.name || null,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
vorschlagen.value = { ...vorschlagen.value, zustand: 'fertig' }
|
||||
emit('suggested')
|
||||
} catch {
|
||||
vorschlagen.value = { ...vorschlagen.value, zustand: 'fehler' }
|
||||
}
|
||||
}
|
||||
|
||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const shots = computed(() =>
|
||||
@@ -86,6 +112,37 @@ onUnmounted(() => {
|
||||
IMDb
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="vorschlag">
|
||||
<template v-if="vorschlagen.zustand === 'fertig'">
|
||||
<span class="ok">Für den Filmabend vorgeschlagen.</span>
|
||||
</template>
|
||||
<template v-else-if="schonVorgeschlagen">
|
||||
<span class="ok">Dieser Film steht schon auf dem Rad.</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="meinVorschlag" class="hinweis">
|
||||
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
|
||||
Vorschlag ersetzt ihn.
|
||||
</span>
|
||||
<input
|
||||
v-model="vorschlagen.name"
|
||||
type="text"
|
||||
placeholder="Dein Name (optional)"
|
||||
maxlength="40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||
@click="abschicken"
|
||||
>
|
||||
Vorschlagen
|
||||
</button>
|
||||
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||||
Hat nicht geklappt.
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -257,6 +314,57 @@ h2 {
|
||||
background: #f5c518;
|
||||
}
|
||||
|
||||
.vorschlag {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.vorschlag input {
|
||||
width: 11rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vorschlag button {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.vorschlag button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.vorschlag button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
flex-basis: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #6bbf7a;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fehler {
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.netflix:hover,
|
||||
.imdb:hover {
|
||||
filter: brightness(1.15);
|
||||
|
||||
159
frontend/src/components/RangeFilter.vue
Normal file
159
frontend/src/components/RangeFilter.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
min: { type: Number, required: true },
|
||||
max: { type: Number, required: true },
|
||||
step: { type: Number, default: 1 },
|
||||
// Bei den Stimmen liegt der Median weit unter dem Maximum. Linear säßen
|
||||
// fast alle Filme im linken Prozent des Reglers.
|
||||
gestaucht: { type: Boolean, default: false },
|
||||
format: { type: Function, default: (v) => String(v) },
|
||||
})
|
||||
|
||||
const value = defineModel({ type: Array, required: true })
|
||||
|
||||
const SCHRITTE = 1000
|
||||
|
||||
function zuRegler(wert) {
|
||||
if (props.max === props.min) return 0
|
||||
const anteil = (wert - props.min) / (props.max - props.min)
|
||||
return Math.round((props.gestaucht ? Math.sqrt(anteil) : anteil) * SCHRITTE)
|
||||
}
|
||||
|
||||
function zuWert(pos) {
|
||||
const anteil = pos / SCHRITTE
|
||||
const roh = props.min + (props.gestaucht ? anteil ** 2 : anteil) * (props.max - props.min)
|
||||
return Math.round(roh / props.step) * props.step
|
||||
}
|
||||
|
||||
const von = computed({
|
||||
get: () => zuRegler(value.value[0]),
|
||||
set: (pos) => {
|
||||
const neu = Math.min(zuWert(Number(pos)), value.value[1])
|
||||
value.value = [neu, value.value[1]]
|
||||
},
|
||||
})
|
||||
|
||||
const bis = computed({
|
||||
get: () => zuRegler(value.value[1]),
|
||||
set: (pos) => {
|
||||
const neu = Math.max(zuWert(Number(pos)), value.value[0])
|
||||
value.value = [value.value[0], neu]
|
||||
},
|
||||
})
|
||||
|
||||
const aktiv = computed(
|
||||
() => value.value[0] > props.min || value.value[1] < props.max
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="range" :class="{ aktiv }">
|
||||
<div class="kopf">
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="wert">{{ format(value[0]) }} – {{ format(value[1]) }}</span>
|
||||
</div>
|
||||
<div class="regler">
|
||||
<input v-model="von" type="range" :min="0" :max="SCHRITTE" />
|
||||
<input v-model="bis" type="range" :min="0" :max="SCHRITTE" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.kopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wert {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aktiv .wert {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Beide Griffe liegen übereinander, sonst bräuchte es zwei Zeilen */
|
||||
.regler {
|
||||
position: relative;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.regler input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1.1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.regler input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.regler input::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
background: var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.regler input::-moz-range-track {
|
||||
height: 3px;
|
||||
background: var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.regler input::-webkit-slider-thumb {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
margin-top: -0.3rem;
|
||||
background: var(--text);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.regler input::-moz-range-thumb {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
background: var(--text);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aktiv .regler input::-webkit-slider-thumb {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.aktiv .regler input::-moz-range-thumb {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
105
frontend/src/components/SearchTab.vue
Normal file
105
frontend/src/components/SearchTab.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import MovieCard from './MovieCard.vue'
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const searched = ref(false)
|
||||
|
||||
let timer = null
|
||||
let laufende = 0
|
||||
|
||||
async function run(term) {
|
||||
if (term.trim().length < 2) {
|
||||
results.value = []
|
||||
searched.value = false
|
||||
return
|
||||
}
|
||||
const meine = ++laufende
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
|
||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||
const data = await resp.json()
|
||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||
if (meine !== laufende) return
|
||||
results.value = data
|
||||
searched.value = true
|
||||
} catch (err) {
|
||||
if (meine === laufende) error.value = err.message
|
||||
} finally {
|
||||
if (meine === laufende) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, (term) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => run(term), 350)
|
||||
})
|
||||
|
||||
async function open(movie) {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-tab">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="big-search"
|
||||
placeholder="Film suchen — egal wo er läuft…"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<p v-if="loading" class="note">Suche läuft…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="searched && !results.length" class="note">
|
||||
Nichts gefunden für „{{ query }}“.
|
||||
</p>
|
||||
<p v-else-if="!searched" class="note">
|
||||
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
|
||||
der Katalog.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in results"
|
||||
:key="movie.id"
|
||||
:movie="movie"
|
||||
@open="open"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.big-search {
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -17,6 +17,12 @@ const rating = computed(() =>
|
||||
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
|
||||
)
|
||||
|
||||
const meta = computed(() =>
|
||||
[current.value.year || 'Jahr unbekannt', current.value.runtime && `${current.value.runtime} Min.`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
)
|
||||
|
||||
function step(delta) {
|
||||
const count = props.parts.length
|
||||
index.value = (index.value + delta + count) % count
|
||||
@@ -50,7 +56,7 @@ function step(delta) {
|
||||
<div class="body">
|
||||
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
|
||||
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
||||
<p class="meta">{{ current.year || 'Jahr unbekannt' }}</p>
|
||||
<p class="meta">{{ meta }}</p>
|
||||
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
||||
</div>
|
||||
</article>
|
||||
@@ -123,6 +129,14 @@ function step(delta) {
|
||||
}
|
||||
|
||||
h2 {
|
||||
/* Feste Höhe für drei Zeilen: sonst rutschen die Kacheln einer Reihe
|
||||
unterschiedlich weit nach unten */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
min-height: calc(3 * 1.3em);
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
@@ -143,6 +157,7 @@ h2:hover {
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
min-height: calc(4 * 1.45em);
|
||||
flex: 1;
|
||||
color: #b9b9c6;
|
||||
font-size: 0.85rem;
|
||||
|
||||
433
frontend/src/components/WheelTab.vue
Normal file
433
frontend/src/components/WheelTab.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
|
||||
const items = ref([])
|
||||
const mine = ref(null)
|
||||
const laden = ref(true)
|
||||
const fehler = ref('')
|
||||
|
||||
const winkel = ref(0)
|
||||
const dreht = ref(false)
|
||||
const gewinner = ref(null)
|
||||
const dauer = ref(4.2)
|
||||
|
||||
const GROESSE = 420
|
||||
const MITTE = GROESSE / 2
|
||||
const RADIUS = MITTE - 6
|
||||
|
||||
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
||||
// verschwimmt der Zeiger mit dem Rad
|
||||
const FARBEN = [
|
||||
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
||||
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
||||
]
|
||||
|
||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||
let quelle = null
|
||||
|
||||
function verbinden() {
|
||||
quelle = new EventSource('/api/events')
|
||||
quelle.onmessage = (ereignis) => {
|
||||
const nachricht = JSON.parse(ereignis.data)
|
||||
if (nachricht.type === 'suggestions') laden_()
|
||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||
}
|
||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||
}
|
||||
|
||||
function abspielen(ergebnis, uebersprungen = 0) {
|
||||
gewinner.value = null
|
||||
dreht.value = true
|
||||
|
||||
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
|
||||
if (uebersprungen > 0) {
|
||||
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
|
||||
dauer.value = rest
|
||||
} else {
|
||||
dauer.value = ergebnis.dauer
|
||||
}
|
||||
|
||||
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
|
||||
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
|
||||
|
||||
window.setTimeout(() => {
|
||||
dreht.value = false
|
||||
gewinner.value = ergebnis.gewinner
|
||||
}, rest * 1000)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
|
||||
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
|
||||
dauer.value = 0
|
||||
winkel.value = daten.result.winkel
|
||||
gewinner.value = daten.result.gewinner
|
||||
}
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
async function laden_() {
|
||||
laden.value = true
|
||||
fehler.value = ''
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions')
|
||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||
const daten = await resp.json()
|
||||
items.value = daten.items
|
||||
mine.value = daten.mine
|
||||
} catch (err) {
|
||||
fehler.value = err.message
|
||||
} finally {
|
||||
laden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await laden_()
|
||||
await laufendenDrehHolen()
|
||||
verbinden()
|
||||
})
|
||||
|
||||
onUnmounted(() => quelle?.close())
|
||||
|
||||
defineExpose({ neu: laden_ })
|
||||
|
||||
const segmente = computed(() => {
|
||||
const n = items.value.length
|
||||
if (!n) return []
|
||||
const schritt = 360 / n
|
||||
return items.value.map((item, i) => {
|
||||
const start = i * schritt
|
||||
const ende = start + schritt
|
||||
const mitte = start + schritt / 2
|
||||
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
|
||||
// von außen nach innen
|
||||
const gespiegelt = mitte > 180
|
||||
return {
|
||||
item,
|
||||
farbe: FARBEN[i % FARBEN.length],
|
||||
pfad: kreisstueck(start, ende),
|
||||
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
|
||||
gespiegelt,
|
||||
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
|
||||
breit: schritt > 9,
|
||||
kurz: kuerzen(item.title, schritt),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function kuerzen(titel, schritt) {
|
||||
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
|
||||
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
|
||||
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
|
||||
}
|
||||
|
||||
function punkt(grad) {
|
||||
const rad = ((grad - 90) * Math.PI) / 180
|
||||
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
|
||||
}
|
||||
|
||||
function kreisstueck(von, bis) {
|
||||
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
|
||||
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
|
||||
if (bis - von >= 360) {
|
||||
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
|
||||
MITTE + RADIUS
|
||||
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
|
||||
}
|
||||
const [x1, y1] = punkt(von)
|
||||
const [x2, y2] = punkt(bis)
|
||||
const gross = bis - von > 180 ? 1 : 0
|
||||
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
|
||||
}
|
||||
|
||||
async function drehen() {
|
||||
if (dreht.value || !items.value.length) return
|
||||
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
||||
try {
|
||||
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
|
||||
} catch {
|
||||
fehler.value = 'Drehen hat nicht geklappt.'
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wheel-tab">
|
||||
<div class="buehne">
|
||||
<p v-if="laden" class="note">Lade Vorschläge…</p>
|
||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!items.length" class="note">
|
||||
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="rad-halter">
|
||||
<div class="zeiger"></div>
|
||||
<svg
|
||||
class="rad"
|
||||
:class="{ dreht }"
|
||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||
:style="{
|
||||
transform: `rotate(${winkel}deg)`,
|
||||
transitionDuration: `${dauer}s`,
|
||||
}"
|
||||
>
|
||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<text
|
||||
v-if="seg.breit"
|
||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||
:y="MITTE + 4"
|
||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||
class="beschriftung"
|
||||
>
|
||||
{{ seg.kurz }}
|
||||
</text>
|
||||
</g>
|
||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
|
||||
{{ dreht ? 'dreht…' : 'Drehen' }}
|
||||
</button>
|
||||
|
||||
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||
<img
|
||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:alt="gewinner.title"
|
||||
/>
|
||||
<div>
|
||||
<p class="label">Der Film des Abends</p>
|
||||
<h2>{{ gewinner.title }}</h2>
|
||||
<p class="jahr">
|
||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<aside v-if="items.length" class="liste">
|
||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
class="eintrag"
|
||||
:class="{ eigen: item.id === mine }"
|
||||
@click="oeffnen(item)"
|
||||
>
|
||||
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
|
||||
<span class="titel">{{ item.title }}</span>
|
||||
<span class="von">{{ item.name || '—' }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wheel-tab {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.buehne {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.rad-halter {
|
||||
position: relative;
|
||||
width: min(420px, 80vw);
|
||||
}
|
||||
|
||||
.rad {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
|
||||
}
|
||||
|
||||
.beschriftung {
|
||||
fill: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(0, 0, 0, 0.45);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.zeiger {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: -11px;
|
||||
border-top: 20px solid var(--text);
|
||||
border-right: 11px solid transparent;
|
||||
border-left: 11px solid transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.gewinner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
max-width: 420px;
|
||||
padding: 0.9rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.gewinner img {
|
||||
width: 74px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gewinner h2 {
|
||||
margin: 0.2rem 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.jahr {
|
||||
color: var(--muted);
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
color: var(--text);
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.eintrag:hover {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.eintrag.eigen .titel {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.punkt {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.titel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.von {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 3rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wheel-tab {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user