update
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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
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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
@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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +142,7 @@ onUnmounted(() => abbestellen?.())
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="info-tab">
|
<div class="info-tab">
|
||||||
|
<div class="text">
|
||||||
<p v-if="laden" class="note">Lade …</p>
|
<p v-if="laden" class="note">Lade …</p>
|
||||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
<MarkdownFeld
|
<MarkdownFeld
|
||||||
@@ -79,13 +161,273 @@ onUnmounted(() => abbestellen?.())
|
|||||||
<div class="markdown" v-html="rendern(text)"></div>
|
<div class="markdown" v-html="rendern(text)"></div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user