462 lines
12 KiB
Vue
462 lines
12 KiB
Vue
<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>
|