Files
horror/frontend/src/components/WheelTab.vue
T
2026-09-29 00:54:58 +02:00

476 lines
10 KiB
Vue

<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
host: Boolean,
angemeldet: Boolean,
// Wer gerade gewählt ist — "Dabei ohne Vorschlag" gehört zur Person
ich: { type: Object, default: null },
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() },
})
const emit = defineEmits(['open', 'reset'])
const items = ref([])
// Wer ohne Vorschlag dabei ist — zählt beim Dreh als anwesend
const dabei = ref([])
const dabeiMine = ref(false)
const laden = ref(true)
const fehler = ref('')
const {
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
} = useZiehung({ items })
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let abbestellen = null
function verbinden() {
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data)
else if (nachricht.type === 'reset') {
zurueckgesetzt()
emit('reset')
laden_()
}
})
}
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
dabei.value = daten.dabei || []
dabeiMine.value = !!daten.dabei_mine
ruhen()
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
async function drehen() {
if (dreht.value || !items.value.length) return
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
// aus und schickt das Ergebnis an alle, auch an uns.
klang()
try {
const resp = await fetch('/api/spin', { method: 'POST' })
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
} catch {
fehler.value = 'Ziehen hat nicht geklappt.'
}
}
async function zuruecksetzen() {
// Der Server meldet den Reset an alle zurück, auch an uns
try {
await fetch('/api/suggestions/alle', { method: 'DELETE' })
} catch {
fehler.value = 'Zurücksetzen hat nicht geklappt.'
}
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
</script>
<template>
<div class="wheel-tab">
<div class="buehne">
<p v-if="fehler" class="note error">{{ fehler }}</p>
<div
class="fenster"
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
>
<div class="marke" :class="{ anlauf, bruch }"></div>
<div
ref="bahn"
class="bahn"
:class="{ dreht }"
:style="{
transform: `translateX(${x}px)`,
transitionDuration: `${dauer}s`,
transitionTimingFunction: kurve,
}"
>
<div
v-for="k in kacheln"
:key="k.key"
class="kachel"
:class="{
treffer: k.treffer && !dreht && gewinner,
gesehen: gesehenIds.has(k.film.movie_id),
}"
:style="{ borderColor: k.farbe }"
>
<img
v-if="posterUrl(k.film.poster_path, 'w342')"
:src="posterUrl(k.film.poster_path, 'w342')"
:alt="k.film.title"
draggable="false"
/>
<span v-else class="ohne-cover">{{ k.film.title }}</span>
</div>
</div>
<p v-if="!items.length && !laden && !ergebnis" class="leer-bahn">
Noch nichts vorgeschlagen.
</p>
</div>
<div v-if="host" class="knoepfe">
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
{{ dreht ? 'läuft…' : 'Ziehen' }}
</button>
<button
class="reset"
type="button"
:disabled="dreht || !items.length"
@click="zuruecksetzen"
>
Zurücksetzen
</button>
</div>
<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 <PersonName :name="gewinner.name" /></span>
</p>
</div>
</div>
</div>
<VorschlagsListe
:items="items"
:laden="laden"
:host="host"
:angemeldet="angemeldet"
:merkliste="merkliste"
:mine="mine"
:gesehen-ids="gesehenIds"
:dabei="dabei"
:dabei-mine="dabeiMine"
@open="oeffnen"
@fehler="fehler = $event"
/>
</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;
min-width: 0;
}
/* ── Die Bahn ────────────────────────────────────────────────────── */
.fenster {
position: relative;
align-self: stretch;
/* 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);
}
.bahn {
position: absolute;
top: 20px;
left: 50%;
display: flex;
gap: var(--luecke);
will-change: transform;
/* Die Kurve setzt das Skript je Phase */
transition-property: transform;
}
.kachel {
position: relative;
flex: 0 0 var(--kachel);
height: calc(var(--kachel) * 1.5);
overflow: hidden;
background: var(--bg-soft);
border: 2px solid;
border-radius: 8px;
transition: transform 0.4s, box-shadow 0.4s;
}
.kachel img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ohne-cover {
display: grid;
height: 100%;
padding: 0.6rem;
place-items: center;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
/* Schon gelaufen — die Ziehung überspringt diesen Film */
.kachel.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
/* Der Gewinner blitzt auf, sobald die Bahn steht */
.kachel.treffer {
z-index: 1;
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
animation: aufblitzen 0.7s ease-out;
}
@keyframes aufblitzen {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 #fff, 0 0 0 #fff;
}
35% {
transform: scale(1.14);
box-shadow: 0 0 0 4px #fff, 0 0 60px #fff;
}
100% {
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
}
}
.marke {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
z-index: 2;
width: 3px;
margin-left: -1.5px;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
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;
}
@keyframes puls {
50% {
transform: scaleX(2.5);
box-shadow: 0 0 26px var(--accent), 0 0 6px #fff;
}
}
/* Die Halterung bricht: die Marke schlackert kurz */
.marke.bruch {
transform-origin: top center;
animation: wackeln 0.6s ease-in-out;
}
@keyframes wackeln {
20% {
transform: rotate(9deg);
}
45% {
transform: rotate(-7deg);
}
70% {
transform: rotate(4deg);
}
100% {
transform: none;
}
}
.leer-bahn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
}
.knoepfe {
display: flex;
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);
font-size: 0.95rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.reset:hover:not(:disabled) {
color: var(--text);
border-color: var(--muted);
}
.reset:disabled {
opacity: 0.4;
cursor: default;
}
.gewinner {
display: flex;
gap: 1rem;
align-items: center;
max-width: 640px;
padding: 0.9rem;
text-align: left;
cursor: pointer;
background: var(--bg-soft);
border: 1px solid var(--accent);
border-radius: 12px;
animation: aufsteigen 0.45s ease-out;
}
@keyframes aufsteigen {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.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;
}
.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>