Files
horror/frontend/src/components/InfoTab.vue
2026-10-07 15:47:07 +02:00

462 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// Ein Markdown-Text, mehr nicht: PayPal, IBAN, Regeln, was man wissen
// muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage
// mit den Punkteregeln.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from '../live'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
host: Boolean,
// Die Runde — daraus wählt der Host, wer ein Blatt bekommt
runde: { type: Array, default: () => [] },
})
const text = ref('')
const laden = ref(true)
const bearbeitet = ref(false)
const entwurf = ref('')
const fehler = ref('')
async function laden_() {
try {
const resp = await fetch('/api/info')
if (!resp.ok) throw new Error('Infos konnten nicht geladen werden')
text.value = (await resp.json()).text
fehler.value = ''
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
function bearbeiten() {
entwurf.value = text.value
bearbeitet.value = true
}
async function speichern() {
const resp = await fetch('/api/info', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: entwurf.value }),
})
if (!resp.ok) {
fehler.value = 'Speichern hat nicht geklappt.'
return
}
bearbeitet.value = false
laden_()
}
// ── Spendenbaum: ein Blatt je Person, das neueste oben ─────────────────
const blaetter = ref([])
const gewaehlt = ref('')
// Der Kontostand läuft als Erstes über den Bildschirm
const kontostand = ref(null)
const standEntwurf = ref('')
async function baumLaden() {
try {
const resp = await fetch('/api/spenden')
if (resp.ok) blaetter.value = await resp.json()
const stand = await fetch('/api/kontostand')
if (stand.ok) kontostand.value = (await stand.json()).betrag
} catch {
// Der Abspann ist Beiwerk — ohne ihn geht die Seite trotzdem
}
}
// Die Wartezeit vor dem Abspann gilt nur für den ersten Durchlauf. Nach
// jedem Durchlauf die Animation neu ansetzen — dann wartet sie wieder.
function neuAnsetzen(e) {
const el = e.target
el.style.animationName = 'none'
void el.offsetHeight
el.style.animationName = ''
mischen()
}
function euro(betrag) {
return betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
async function standSetzen() {
const betrag = standEntwurf.value === '' ? null : Number(standEntwurf.value)
const resp = await fetch('/api/kontostand', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ betrag }),
})
if (resp.ok) kontostand.value = (await resp.json()).betrag
standEntwurf.value = ''
}
// Im Abspann stehen die Namen bei jedem Durchlauf in anderer Reihenfolge —
// niemand steht immer oben
const gemischt = ref([])
function mischen() {
const liste = [...blaetter.value]
for (let i = liste.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[liste[i], liste[j]] = [liste[j], liste[i]]
}
gemischt.value = liste
}
watch(blaetter, mischen)
// Wer noch kein Blatt hat
const ohneBlatt = computed(() =>
props.runde.filter((u) => !blaetter.value.some((b) => b.user_id === u.id)),
)
async function blattSetzen() {
if (!gewaehlt.value) return
const resp = await fetch('/api/spenden', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: Number(gewaehlt.value) }),
})
if (resp.ok) blaetter.value = await resp.json()
gewaehlt.value = ''
}
async function blattWeg(userId) {
const resp = await fetch(`/api/spenden/${userId}`, { method: 'DELETE' })
if (resp.ok) blaetter.value = await resp.json()
}
let abbestellen = null
onMounted(() => {
laden_()
baumLaden()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'info') laden_()
else if (nachricht.type === 'spenden') baumLaden()
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="info-tab">
<div class="text">
<p v-if="laden" class="note">Lade …</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<MarkdownFeld
v-else-if="bearbeitet"
v-model="entwurf"
platzhalter="PayPal, IBAN, Regeln, was man wissen muss …"
knopf="Speichern"
:maxlaenge="8000"
@senden="speichern"
@abbrechen="bearbeitet = false"
/>
<template v-else>
<div class="oben">
<button v-if="host" type="button" class="klein" @click="bearbeiten">Bearbeiten</button>
</div>
<div class="markdown" v-html="rendern(text)"></div>
</template>
</div>
<aside class="abspann">
<!-- Ein Röhrenfernseher: im Bild läuft der Abspann mit den Namen -->
<div class="tv">
<svg class="gehaeuse" viewBox="0 0 320 262" aria-hidden="true">
<rect
x="6"
y="6"
width="308"
height="218"
rx="24"
fill="#2b2522"
stroke="#171311"
stroke-width="3"
/>
<rect x="22" y="22" width="240" height="182" rx="24" fill="#0a0a0c" />
<circle cx="288" cy="64" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
<rect x="287" y="53" width="2" height="8" fill="#171311" />
<circle cx="288" cy="106" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
<rect x="287" y="95" width="2" height="8" fill="#171311" />
<g fill="#171311">
<rect x="278" y="140" width="20" height="3" rx="1.5" />
<rect x="278" y="148" width="20" height="3" rx="1.5" />
<rect x="278" y="156" width="20" height="3" rx="1.5" />
<rect x="278" y="164" width="20" height="3" rx="1.5" />
<rect x="278" y="172" width="20" height="3" rx="1.5" />
</g>
<rect x="56" y="224" width="26" height="34" rx="5" fill="#171311" />
<rect x="238" y="224" width="26" height="34" rx="5" fill="#171311" />
</svg>
<div class="schirm">
<!-- Der Abspann: der Kontostand führt ihn an. Er steht beim Start
schon im Bild, wartet kurz und läuft dann nach oben hinaus -->
<div class="lauf">
<div
v-if="blaetter.length || kontostand !== null"
class="rolle"
:style="{ animationDuration: `${5 + blaetter.length * 1.5}s` }"
@animationiteration="neuAnsetzen"
>
<template v-if="kontostand !== null">
<p class="vorspann">Kontostand</p>
<p class="betrag">{{ euro(kontostand) }}</p>
</template>
<template v-if="blaetter.length">
<p class="vorspann">Supporter</p>
<p v-for="b in gemischt" :key="b.user_id" class="name">{{ b.name }}</p>
</template>
</div>
<p v-else class="niemand">Noch niemand</p>
</div>
<span class="scanlines"></span>
</div>
</div>
<template v-if="host">
<form class="eintrag" @submit.prevent="standSetzen">
<input
v-model="standEntwurf"
type="number"
step="0.01"
:placeholder="kontostand === null ? 'Kontostand in €' : euro(kontostand)"
/>
<button type="submit" class="klein">Speichern</button>
</form>
<form class="eintrag" @submit.prevent="blattSetzen">
<select v-model="gewaehlt">
<option value="">Name …</option>
<option v-for="u in ohneBlatt" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
<button type="submit" class="klein" :disabled="!gewaehlt">Eintragen</button>
</form>
<ul v-if="blaetter.length" class="liste">
<li v-for="b in blaetter" :key="b.user_id">
{{ b.name }}
<button type="button" class="weg" title="Entfernen" @click="blattWeg(b.user_id)">
×
</button>
</li>
</ul>
</template>
</aside>
</div>
</template>
<style scoped>
.info-tab {
display: grid;
/* Zwei gleiche Spalten, links der Text, rechts der Baum — als Block
in der Mitte, mit Luft zu den Rändern */
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: start;
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
}
.text {
max-width: 640px;
}
.abspann {
/* Feste Breite: mit automatischem Rand schrumpft ein Rasterfeld sonst
auf die Eigengröße des SVG zusammen */
width: min(100%, 450px);
margin: 0 auto;
}
/* ── Der Fernseher: Gehäuse als SVG, der Bildschirm liegt darüber ───── */
.tv {
position: relative;
aspect-ratio: 320 / 262;
}
.gehaeuse {
display: block;
width: 100%;
height: 100%;
}
/* Deckt genau die Bildschirmmulde des SVG ab (22/22, 240×182 von 320×262) */
.schirm {
position: absolute;
top: 8.4%;
left: 6.9%;
width: 75%;
height: 69.5%;
display: flex;
flex-direction: column;
overflow: hidden;
background: radial-gradient(ellipse at center, #171a1f 0%, #0a0a0c 75%);
border-radius: 9% / 12%;
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9);
}
/* Die Zeilenstruktur einer Röhre */
.scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.28) 0 2px, transparent 2px 4px);
}
/* Der Abspann: steht beim Start im Bild, läuft nach oben hinaus, von vorn */
.lauf {
position: relative;
flex: 1;
overflow: hidden;
}
.rolle {
position: absolute;
top: 50%;
left: 0;
width: 100%;
text-align: center;
/* Die erste Zeile startet in der Bildmitte, steht eine Sekunde, dann
rollt alles nach oben hinaus */
animation: abspann linear infinite;
animation-delay: 1s;
}
.schirm:hover .rolle {
animation-play-state: paused;
}
/* Am Ende liegt die Unterkante über dem Bildrand: top auf 0, dazu die
eigene Höhe nach oben */
@keyframes abspann {
from {
top: 50%;
transform: translateY(0);
}
to {
top: 0;
transform: translateY(-100%);
}
}
.vorspann {
margin-bottom: 1.2rem;
color: var(--muted);
font-size: 0.9rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.name {
margin-bottom: 1rem;
color: #d9dde2;
font-size: 1.3rem;
letter-spacing: 0.04em;
}
.betrag {
margin-bottom: 2rem;
color: #d9dde2;
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.niemand {
display: grid;
height: 100%;
place-items: center;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.eintrag {
display: flex;
gap: 0.4rem;
margin-top: 1rem;
}
.eintrag select,
.eintrag input {
flex: 1;
min-width: 0;
padding: 0.3rem 0.5rem;
color: var(--text);
font-size: 0.85rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
}
/* Die Namen zum Entfernen — nur der Host sieht die Liste */
.liste {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.8rem;
list-style: none;
}
.liste li {
display: inline-flex;
gap: 0.3rem;
align-items: center;
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.weg {
color: var(--muted);
font-size: 0.95rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
@media (max-width: 900px) {
.info-tab {
grid-template-columns: 1fr;
gap: 2rem;
}
}
.oben {
margin-bottom: 0.6rem;
}
.klein {
padding: 0.2rem 0.55rem;
color: var(--muted);
font-size: 0.72rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>