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