update
This commit is contained in:
@@ -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"]
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
@@ -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
44
backend/app/spenden.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
27
backend/tests/test_spenden.py
Normal file
27
backend/tests/test_spenden.py
Normal 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}
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user