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

@@ -23,4 +23,4 @@ VOLUME /data
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "2"]

View File

@@ -31,7 +31,7 @@ dev: mtx
-@pkill -f "[u]vicorn app.main:app" 2>/dev/null -@pkill -f "[u]vicorn app.main:app" 2>/dev/null
-@pkill -f "[v]ite --port 5173" 2>/dev/null -@pkill -f "[v]ite --port 5173" 2>/dev/null
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173" @echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 & @cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 --timeout-graceful-shutdown 2 &
@cd frontend && npx vite --port 5173 @cd frontend && npx vite --port 5173
stop: mtx-stop stream-stop stop: mtx-stop stream-stop

View File

@@ -21,6 +21,7 @@ from . import (
features, features,
ki, ki,
search, search,
spenden,
streamproxy, streamproxy,
suggestions, suggestions,
sync, sync,
@@ -98,6 +99,7 @@ async def startup():
clips.init() clips.init()
wishlist.init() wishlist.init()
features.init() features.init()
spenden.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update # Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand # ein sauberer Stand
if not HOST_SCHLUESSEL: if not HOST_SCHLUESSEL:
@@ -714,6 +716,60 @@ def info_setzen(
return {"text": db.get_meta("info_text")} return {"text": db.get_meta("info_text")}
# ── Spendenbaum ─────────────────────────────────────────────────────────
@app.get("/api/spenden")
def spenden_liste():
return spenden.alle()
@app.post("/api/spenden")
def spende_eintragen(
response: Response,
user_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
spenden.eintragen(user_id)
events.senden("spenden")
return spenden.alle()
@app.get("/api/kontostand")
def kontostand():
"""Was auf dem Filmabend-Konto liegt — der Host trägt die Zahl ein."""
wert = db.get_meta("kontostand")
return {"betrag": float(wert) if wert else None}
@app.put("/api/kontostand")
def kontostand_setzen(
response: Response,
betrag: float | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
with db.connect() as conn:
db.set_meta(conn, "kontostand", "" if betrag is None else round(betrag, 2))
events.senden("spenden")
return kontostand()
@app.delete("/api/spenden/{user_id}")
def spende_entfernen(
user_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if spenden.entfernen(user_id):
events.senden("spenden")
return spenden.alle()
@app.get("/api/features") @app.get("/api/features")
def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)): def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
@@ -915,7 +971,9 @@ def schutz_setzen(
@app.get("/api/host") @app.get("/api/host")
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)): def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
return {"host": users.ist_host(sid)} # Ob es einen Host-Film gibt, darf jeder wissen — nur nicht welchen.
# Ohne Film hat die Tastenkombination nichts zu fragen.
return {"host": users.ist_host(sid), "film": users.host_film() is not None}
@app.post("/api/host") @app.post("/api/host")
@@ -930,7 +988,8 @@ def host_modus(
Freischalten geht mit dem Schlüssel aus der .env, einmal über Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination /?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
fragt danach, wie beim Anmeldefilm eines Namens. fragt danach, wie beim Anmeldefilm eines Namens. Ist kein Host-Film
gesetzt, reicht die Tastenkombination allein.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
@@ -944,7 +1003,8 @@ def host_modus(
darf = ( darf = (
users.ist_host(sid) users.ist_host(sid)
or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)) or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL))
or (film is not None and movie_id == film) or film is None
or movie_id == film
) )
if not darf: if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel") raise HTTPException(status_code=403, detail="Kein Hostschlüssel")

44
backend/app/spenden.py Normal file
View File

@@ -0,0 +1,44 @@
"""Der Spendenbaum: wer schon einmal etwas überwiesen hat.
Ein Blatt je Person, ohne Betrag. Der Host trägt ein und nimmt weg.
"""
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS spenden (
user_id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def alle():
"""Die Blätter, das neueste zuerst."""
with connect() as conn:
rows = conn.execute(
"SELECT s.user_id, u.name, s.created_at FROM spenden s "
"JOIN users u ON u.id = s.user_id ORDER BY s.created_at DESC, s.user_id DESC"
).fetchall()
return [dict(r) for r in rows]
def eintragen(user_id):
"""Ein zweites Mal ändert nichts — ein Blatt je Person."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO spenden (user_id, created_at) VALUES (?, ?)",
(user_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
def entfernen(user_id):
with connect() as conn:
return conn.execute("DELETE FROM spenden WHERE user_id = ?", (user_id,)).rowcount > 0

View File

@@ -19,7 +19,7 @@ os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
import pytest # noqa: E402 import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from app import clips, db, features, main, suggestions, users, watched, wishlist # noqa: E402 from app import clips, db, features, main, spenden, suggestions, users, watched, wishlist # noqa: E402
# Zählt, ob der Startup doch einen Sync anstoßen wollte # Zählt, ob der Startup doch einen Sync anstoßen wollte
_SYNC_AUFRUFE = [] _SYNC_AUFRUFE = []
@@ -50,7 +50,7 @@ def sync_aufrufe():
def frische_db(app_start): def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung.""" """Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
db.DB_PATH.unlink(missing_ok=True) db.DB_PATH.unlink(missing_ok=True)
for modul in (db, users, suggestions, watched, clips, wishlist, features): for modul in (db, users, suggestions, watched, clips, wishlist, features, spenden):
modul.init() modul.init()
main.letzter_dreh.update(ergebnis=None, start=0.0) main.letzter_dreh.update(ergebnis=None, start=0.0)

View File

@@ -151,13 +151,25 @@ def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus):
assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien" assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien"
def test_ohne_schluessel_kein_hostmodus(zuschauer): def hostfilm_setzen(client, hostmodus, movie_id):
"""Die Tastenkombination allein reicht nicht mehr.""" """Ein Host setzt den Host-Film und gibt die Rechte wieder ab."""
hostmodus(client)
client.post("/api/host/film", json={"movie_id": movie_id})
client.post("/api/host", json={"an": False})
def test_ohne_hostfilm_reicht_die_tastenkombination(zuschauer):
assert zuschauer.post("/api/host", json={"an": True}).json()["host"] is True
def test_mit_hostfilm_kein_hostmodus_ohne_film(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403 assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False assert zuschauer.get("/api/host").json()["host"] is False
def test_falscher_schluessel_kein_hostmodus(zuschauer): def test_falscher_schluessel_kein_hostmodus(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
antwort = zuschauer.post( antwort = zuschauer.post(
"/api/host", json={"an": True, "schluessel": "geraten"} "/api/host", json={"an": True, "schluessel": "geraten"}
) )
@@ -173,7 +185,8 @@ def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus):
assert zuschauer.get("/api/host").json()["host"] is True assert zuschauer.get("/api/host").json()["host"] is True
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus): def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
hostmodus(zuschauer) hostmodus(zuschauer)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# Jetzt ist die Sitzung wieder gewöhnlich # Jetzt ist die Sitzung wieder gewöhnlich
@@ -189,9 +202,12 @@ def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
# Nur der Host darf den Host-Film setzen oder sehen # Nur der Host darf den Host-Film setzen oder sehen
assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403 assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403
assert client.get("/api/host/film").status_code == 403 assert client.get("/api/host/film").status_code == 403
# Ob es einen gibt, sieht jeder — welcher, nicht
assert client.get("/api/host").json() == {"host": False, "film": False}
hostmodus(zuschauer) hostmodus(zuschauer)
assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True
assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1) assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1)
assert client.get("/api/host").json() == {"host": False, "film": True}
# Falscher Film: nichts; richtiger Film: Host # Falscher Film: nichts; richtiger Film: Host
assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403 assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403
@@ -199,8 +215,8 @@ def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True
assert client.get("/api/host").json()["host"] is True assert client.get("/api/host").json()["host"] is True
# Aufheben: der Film öffnet nichts mehr # Aufheben: ohne Host-Film reicht die Tastenkombination
zuschauer.post("/api/host/film", json={"movie_id": None}) zuschauer.post("/api/host/film", json={"movie_id": None})
assert zuschauer.get("/api/host/film").json()["film"] is None assert zuschauer.get("/api/host/film").json()["film"] is None
client.post("/api/host", json={"an": False}) client.post("/api/host", json={"an": False})
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).status_code == 403 assert client.post("/api/host", json={"an": True}).json()["host"] is True

View File

@@ -0,0 +1,27 @@
"""Der Spendenbaum: ein Blatt je Person, nur der Host pflegt ihn."""
def test_ein_blatt_je_person_nur_vom_host(client, zuschauer, hostmodus, anmelden):
anmelden(client, "Anna")
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get("/api/spenden").json() == []
assert client.post("/api/spenden", json={"user_id": anna}).status_code == 403
hostmodus(zuschauer)
blaetter = zuschauer.post("/api/spenden", json={"user_id": anna}).json()
assert [b["name"] for b in blaetter] == ["Anna"]
# Ein zweites Mal: immer noch ein Blatt
assert len(zuschauer.post("/api/spenden", json={"user_id": anna}).json()) == 1
assert client.get("/api/spenden").json()[0]["name"] == "Anna"
assert client.delete(f"/api/spenden/{anna}").status_code == 403
assert zuschauer.delete(f"/api/spenden/{anna}").json() == []
def test_kontostand_setzt_nur_der_host(client, zuschauer, hostmodus):
assert client.get("/api/kontostand").json() == {"betrag": None}
assert client.put("/api/kontostand", json={"betrag": 12.5}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/kontostand", json={"betrag": 12.5}).json() == {"betrag": 12.5}
assert client.get("/api/kontostand").json() == {"betrag": 12.5}
assert zuschauer.put("/api/kontostand", json={"betrag": None}).json() == {"betrag": None}

View File

@@ -432,7 +432,7 @@ onMounted(load)
@changed="ladeWuensche" @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" /> <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 // Ein Markdown-Text, mehr nicht: PayPal, IBAN, Regeln, was man wissen
// muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage // muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage
// mit den Punkteregeln. // mit den Punkteregeln.
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live' import { abonnieren } from '../live'
import { rendern } from '../markdown' import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue' 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 text = ref('')
const laden = ref(true) const laden = ref(true)
@@ -47,12 +51,89 @@ async function speichern() {
laden_() 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 let abbestellen = null
onMounted(() => { onMounted(() => {
laden_() laden_()
baumLaden()
abbestellen = abonnieren((nachricht) => { abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'info') laden_() if (nachricht.type === 'info') laden_()
else if (nachricht.type === 'spenden') baumLaden()
}) })
}) })
@@ -61,31 +142,292 @@ onUnmounted(() => abbestellen?.())
<template> <template>
<div class="info-tab"> <div class="info-tab">
<p v-if="laden" class="note">Lade …</p> <div class="text">
<p v-else-if="fehler" class="note error">{{ fehler }}</p> <p v-if="laden" class="note">Lade …</p>
<MarkdownFeld <p v-else-if="fehler" class="note error">{{ fehler }}</p>
v-else-if="bearbeitet" <MarkdownFeld
v-model="entwurf" v-else-if="bearbeitet"
platzhalter="PayPal, IBAN, Regeln, was man wissen muss …" v-model="entwurf"
knopf="Speichern" platzhalter="PayPal, IBAN, Regeln, was man wissen muss …"
:maxlaenge="8000" knopf="Speichern"
@senden="speichern" :maxlaenge="8000"
@abbrechen="bearbeitet = false" @senden="speichern"
/> @abbrechen="bearbeitet = false"
<template v-else> />
<div class="oben"> <template v-else>
<button v-if="host" type="button" class="klein" @click="bearbeiten">Bearbeiten</button> <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>
<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> </div>
</template> </template>
<style scoped> <style scoped>
.info-tab { .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; 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 { .oben {
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }

View File

@@ -22,9 +22,9 @@ async function hostSetzen(an, schluessel = null, movieId = null) {
async function hostStand() { async function hostStand() {
try { try {
const resp = await fetch('/api/host') 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 { } catch {
return false return { host: false, film: false }
} }
} }
@@ -103,7 +103,7 @@ export function useEinstellungen() {
anzeige.value = true anzeige.value = true
return return
} }
freigeschaltet.value = await hostStand() freigeschaltet.value = (await hostStand()).host
}) })
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0')) watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
@@ -121,13 +121,14 @@ export function useEinstellungen() {
return true return true
} }
function tastatur(e) { async function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst // e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen // je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') { if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault() e.preventDefault()
if (freigeschaltet.value) anzeige.value = !anzeige.value 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
} }
} }