update
This commit is contained in:
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