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
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 "[v]ite --port 5173" 2>/dev/null
@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
stop: mtx-stop stream-stop

View File

@@ -21,6 +21,7 @@ from . import (
features,
ki,
search,
spenden,
streamproxy,
suggestions,
sync,
@@ -98,6 +99,7 @@ async def startup():
clips.init()
wishlist.init()
features.init()
spenden.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
if not HOST_SCHLUESSEL:
@@ -714,6 +716,60 @@ def info_setzen(
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")
def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
@@ -915,7 +971,9 @@ def schutz_setzen(
@app.get("/api/host")
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
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")
@@ -930,7 +988,8 @@ def host_modus(
Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?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
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
@@ -944,7 +1003,8 @@ def host_modus(
darf = (
users.ist_host(sid)
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:
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
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
_SYNC_AUFRUFE = []
@@ -50,7 +50,7 @@ def sync_aufrufe():
def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
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()
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"
def test_ohne_schluessel_kein_hostmodus(zuschauer):
"""Die Tastenkombination allein reicht nicht mehr."""
def hostfilm_setzen(client, hostmodus, movie_id):
"""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.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(
"/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
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)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# 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
assert client.post("/api/host/film", json={"movie_id": film(1)}).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)
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 client.get("/api/host").json() == {"host": False, "film": True}
# Falscher Film: nichts; richtiger Film: Host
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.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})
assert zuschauer.get("/api/host/film").json()["film"] is None
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"
/>
<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
}
}