Files
horror/frontend/src/components/WheelTab.vue
2026-09-16 12:09:38 +02:00

704 lines
17 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
const props = defineProps({
host: Boolean,
// 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([])
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 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()
})
}
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
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
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
// Das Cover liegt als Rechteck radial im Stück: von der Nabe nach
// außen, so breit wie die Sehne am Rand. So landet die Postermitte
// auf halbem Radius — dort, wo das Stück am besten zu sehen ist.
// Ein einziger Vorschlag ist ein Vollkreis — da gibt es keine Sehne,
// das Bild deckt dann den ganzen Kreis als Quadrat ab.
const voll = n === 1
const sehne = 2 * RADIUS * Math.sin(((schritt / 2) * Math.PI) / 180)
const bildBreite = voll ? 2 * RADIUS : Math.max(sehne + 12, 60)
return {
item,
farbe: FARBEN[i % FARBEN.length],
pfad: kreisstueck(start, ende),
bild: posterUrl(item.poster_path, 'w342'),
bildX: MITTE - bildBreite / 2,
bildY: MITTE - RADIUS - (voll ? 0 : 6),
bildBreite,
bildHoehe: voll ? 2 * RADIUS : RADIUS + 6,
// Im Vollkreis läge die Segmentmitte bei 180° — das Cover stünde
// kopfüber. Ohne Drehung bleibt es aufrecht.
bildDreh: voll ? 0 : mitte,
mitte,
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.'
}
}
function zurueckgesetzt() {
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
dauer.value = 0
winkel.value = 0
gewinner.value = null
dreht.value = false
emit('reset')
laden_()
}
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.'
}
}
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
// anderen — für Abende, an denen es keiner gruselig mag.
const nurHorror = ref(true)
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
const wuerfelt = ref(false)
function nachGenre(horror) {
return sammlung.value.filter(
(e) => (e.genres || []).includes('Horror') === horror
)
}
const auswahl = computed(() => nachGenre(nurHorror.value))
const anzahlHorror = computed(() => nachGenre(true).length)
const anzahlOhne = computed(() => nachGenre(false).length)
async function wuerfeln() {
const treffer = auswahl.value
if (!treffer.length || wuerfelt.value) return
wuerfelt.value = true
const film = treffer[Math.floor(Math.random() * treffer.length)]
try {
const resp = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: film.movie_id }),
})
if (!resp.ok) throw new Error()
} catch {
fehler.value = 'Eintragen hat nicht geklappt.'
} finally {
wuerfelt.value = false
}
}
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="fehler" class="note error">{{ fehler }}</p>
<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`,
}"
>
<defs>
<!-- Zur Nabe hin wird es dunkel, damit der Titel lesbar bleibt -->
<radialGradient
id="rad-schatten"
gradientUnits="userSpaceOnUse"
:cx="MITTE"
:cy="MITTE"
:r="RADIUS"
>
<stop offset="0" stop-color="#0a0a0c" stop-opacity="0.92" />
<stop offset="0.45" stop-color="#0a0a0c" stop-opacity="0.55" />
<stop offset="1" stop-color="#0a0a0c" stop-opacity="0.1" />
</radialGradient>
<clipPath v-for="seg in segmente" :id="`seg-${seg.item.id}`" :key="seg.item.id">
<path :d="seg.pfad" />
</clipPath>
</defs>
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<g :clip-path="`url(#seg-${seg.item.id})`">
<path :d="seg.pfad" :fill="seg.farbe" />
<image
v-if="seg.bild"
:href="seg.bild"
:x="seg.bildX"
:y="seg.bildY"
:width="seg.bildBreite"
:height="seg.bildHoehe"
preserveAspectRatio="xMidYMid slice"
:transform="`rotate(${seg.bildDreh} ${MITTE} ${MITTE})`"
/>
<path :d="seg.pfad" fill="url(#rad-schatten)" />
</g>
<!-- Der farbige Rand ist das Gegenstück zum Punkt in der Liste -->
<path :d="seg.pfad" fill="none" :stroke="seg.farbe" stroke-width="3" />
<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>
<div v-if="host" class="knoepfe">
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
{{ dreht ? 'dreht…' : 'Drehen' }}
</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 {{ gewinner.name }}</span>
</p>
</div>
</div>
</div>
<aside class="liste">
<div v-if="!host" class="sammlung-wuerfeln">
<div class="schalter">
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
Horror ({{ anzahlHorror }})
</button>
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
Kein Horror ({{ anzahlOhne }})
</button>
</div>
<button
type="button"
class="wuerfeln"
:disabled="!auswahl.length || wuerfelt"
@click="wuerfeln"
>
Zufällig aus Sammlung
</button>
</div>
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
type="button"
class="eintrag"
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
@click="oeffnen(item)"
>
<span
class="cover"
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
>
<img
v-if="posterUrl(item.poster_path, 'w154')"
:src="posterUrl(item.poster_path, 'w154')"
:alt="item.title"
loading="lazy"
/>
</span>
<span class="daten">
<span class="titel">{{ item.title }}</span>
<span class="meta">
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
</span>
<span v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</span>
<span class="von">{{ item.name || 'Anonym' }}</span>
</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;
/* Unter der Schrift liegt jetzt ein Cover statt einer Fläche — die
Kontur muss auch auf hellen Bildstellen tragen */
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.75);
stroke-width: 3.5px;
}
.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;
}
.knoepfe {
display: flex;
gap: 0.7rem;
}
.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;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
.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;
}
.sammlung-wuerfeln {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.6rem;
}
.schalter {
display: flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.schalter button {
flex: 1;
padding: 0.45rem 0.9rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
}
.schalter button.an {
color: var(--text);
background: var(--bg-soft);
}
.wuerfeln {
padding: 0.5rem 1.1rem;
font-size: 0.88rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
}
.wuerfeln:hover:not(:disabled) {
border-color: var(--accent);
}
.wuerfeln:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.5;
}
.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;
gap: 0.7rem;
align-items: flex-start;
width: 100%;
padding: 0.55rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 8px;
}
/* Schon gelaufen — die Ziehung überspringt diesen Film */
.eintrag.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.eintrag.gesehen:hover {
opacity: 1;
filter: none;
}
.eintrag:hover {
background: var(--bg-soft);
}
/* Der farbige Rand verbindet den Eintrag mit seinem Stück auf dem Rad */
.cover {
display: block;
width: 46px;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-soft);
border-left: 3px solid;
border-radius: 3px;
}
.cover img {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
}
.daten {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
padding-top: 0.1rem;
}
.titel {
overflow: hidden;
font-weight: 600;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.eintrag.eigen .titel {
color: var(--accent);
}
.meta,
.genres,
.von {
overflow: hidden;
color: var(--muted);
font-size: 0.74rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.von {
margin-top: 0.15rem;
font-size: 0.72rem;
opacity: 0.75;
}
.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>