476 lines
10 KiB
Vue
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>
|