This commit is contained in:
team3
2026-10-07 15:41:41 +02:00
parent b65f4d75f4
commit 2f771a9c30
10 changed files with 527 additions and 37 deletions

View File

@@ -432,7 +432,7 @@ onMounted(load)
@changed="ladeWuensche"
/>
<InfoTab v-else-if="activeTab === 'info'" :host="host" />
<InfoTab v-else-if="activeTab === 'info'" :host="host" :runde="runde" />
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />

View File

@@ -2,12 +2,16 @@
// 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 { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
defineProps({ host: Boolean })
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)
@@ -47,12 +51,89 @@ async function speichern() {
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 = ''
}
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 jedes Mal in anderer Reihenfolge — niemand
// steht immer oben
const gemischt = computed(() => {
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]]
}
return liste
})
// 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()
})
})
@@ -61,31 +142,292 @@ onUnmounted(() => abbestellen?.())
<template>
<div class="info-tab">
<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 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>
<div class="markdown" v-html="rendern(text)"></div>
</template>
<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;
}

View File

@@ -22,9 +22,9 @@ async function hostSetzen(an, schluessel = null, movieId = null) {
async function hostStand() {
try {
const resp = await fetch('/api/host')
return resp.ok ? (await resp.json()).host : false
return resp.ok ? await resp.json() : { host: false, film: false }
} catch {
return false
return { host: false, film: false }
}
}
@@ -103,7 +103,7 @@ export function useEinstellungen() {
anzeige.value = true
return
}
freigeschaltet.value = await hostStand()
freigeschaltet.value = (await hostStand()).host
})
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
@@ -121,13 +121,14 @@ export function useEinstellungen() {
return true
}
function tastatur(e) {
async function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
if (freigeschaltet.value) anzeige.value = !anzeige.value
else hostLogin.value = true
// Ohne Host-Film kommt man so rein; lehnt der Server ab, gibt es einen
else if (!(await hostAnmelden(null))) hostLogin.value = true
}
}