Files
dogfather-universe/verwaltung.html
T
DogFatherGitandClaude Opus 5 efe98e32a7 Sprung zum Inhalt: Seite ohne Maus in einem Schritt bedienbar
Der Tastatur-Test (pruef-tastatur.mjs, neu) zeigte auf allen fünf
geprüften Seiten dasselbe Bild: jedes Bedienelement erreichbar, Fokus
durchgehend sichtbar, keine Tastaturfalle -- aber kein Sprunglink.

Ohne ihn muss sich jemand, der die Tastatur benutzt, auf JEDER Seite
erneut durch das komplette Menü tabben (31 bis 52 Punkte), bevor der
eigentliche Inhalt beginnt. WCAG 2.4.1 verlangt genau diesen Ausweg.

An einer Stelle gelöst statt in 35 Dateien: renderHeader() in main.js
setzt das Sprungziel und stellt den Link davor.

Das tabindex="-1" am <main> ist der Teil, der meistens fehlt: ohne ihn
verschiebt der Sprung in Chrome und Safari nur die Bildlaufleiste, der
Tastaturfokus bleibt in der Navigation -- der nächste Tab landet wieder
im Menü und der Sprung war wirkungslos.

Cache-Buster auf 20260827a (244 Stellen), sonst bekäme niemand die
geänderte main.js und main.css ausgeliefert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 01:21:31 +02:00

2955 lines
162 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de" data-theme="dogfather">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Verwaltung — DOGFATHER UNIVERSE</title>
<meta name="description" content="Interne Verwaltung — DogiCrew-Supporter, Prämien und Kollektionen. Nur mit Zugangscode." />
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
<!-- Eigene, separat installierbare App (20.08.2026, Nutzer-Wunsch: "die
Verwaltungsseite auch herunterladen können, getrennt von der Website
installiert") — eigenes Manifest mit eigener "id"/eigenem "scope"
(nur diese eine Seite), eigenem Namen + eigenem Icon (Violett/Gold statt
Babyblau, passend zum "Jewelen-Tresor"-Look dieser Seite). Der
Service Worker (/sw.js, siehe main.js) läuft site-weit auf Scope "/"
und deckt damit auch diese Seite automatisch mit ab — kein zweiter
Service Worker nötig, nur ein zweites Manifest. -->
<link rel="manifest" href="/manifest-verwaltung.json" />
<link rel="apple-touch-icon" href="assets/img/apple-touch-icon-admin.png" />
<meta name="apple-mobile-web-app-title" content="DogiCrew-Verwaltung" />
<meta name="theme-color" content="#0e0a16" />
<link rel="stylesheet" href="assets/css/main.css?v=20260827a" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260827a" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260827a" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260827a" />
<style>
/* =====================================================================
verwaltung.html — Dogis privater Command-Center-Bereich.
Nutzer-Wunsch 04.08.2026 (1. Runde): "die Verwaltungsseite wird mein
privater Bereich und der soll so ultra krank geil aussehen, hoch
speziell, auf allen Ebenen."
Nutzer-Wunsch 04.08.2026 (2. Runde, nach Screenshot der 1. Version):
"ich will das noch viel spezieller und andere Farben. falls du dir
auch noch mehr da einfallen lassen kannst, dann mach es — ich will
dass da alles perfekt ist und nichts fehlt." → deshalb hier bewusst
WEG vom reinen Gold-Einheitslook (der ist schon auf jeder öffentlichen
DogiCrew-Seite verbaut) HIN zu einem eigenen "Juwelen-Tresor"-Look:
jede Kennzahl hat ihren eigenen Edelstein-Farbton (Amethyst/Smaragd/
Gold/Rosé), der Rahmen-Schimmer läuft durch alle vier Farben statt nur
Gold. Zusätzlich fehlende Admin-Funktionen ergänzt, die in einer
echten Verwaltung nicht fehlen dürfen: Live-Suche, sortierbare
Spalten, CSV-Export, mehr Detail-Infos (E-Mail verifiziert, zuletzt
aktiv, Kündigungs-/Zugangsdaten), Live-Uhrzeit "zuletzt aktualisiert".
Diese Seite liegt zusätzlich hinter der site-weiten Zugangsschranke
(gate-worker.js) UND braucht zusätzlich den internen Team-Code —
doppelt abgesichert, weil hier echte Supporter-Daten (E-Mails,
Adressen, Zahlungen) sichtbar sind. Bewusst NUR Deutsch (kein
data-i18n) — reines internes Werkzeug, nur für Dogi/Team.
Nutzer-Wunsch 04.08.2026 (4. Runde): "überall wo gelb ist, Babyblau
benutzen" — die komplette Gold-Farbfamilie (#d4af37/#ffe9a8 sowie die
zugehörigen rgba-Werte) wurde 1:1 durch Babyblau ersetzt — auch
innerhalb der mehrfarbigen Schimmer-Ränder, Glows und der Kennzahl-
Kachel "Monatsumsatz" (vorher Gold, Klasse dafür von `.vw-stat--gold`
in `.vw-stat--blau` umbenannt). Amethyst/Smaragd/Rosé bei den anderen
drei Kennzahlen bleiben unverändert, die waren nie gelb.
Nutzer-Wunsch 04.08.2026 (5. Runde): "das Babyblau soll erkennbarer
sein" — das ursprünglich verwendete, sehr helle/blasse Standard-
Babyblau der Seite (#8fd9ea/#c8f0f7, aus theme-dogfather.css) ging auf
dem dunklen Verwaltungs-Hintergrund optisch unter und wirkte neben den
gesättigten Amethyst-/Smaragd-/Rosé-Kacheln blass. NUR auf dieser
Seite (nicht am Marken-Farbwert selbst, der bleibt für die restliche
Website unverändert) durch ein kräftigeres, saturierteres Sky-Blau
ersetzt: #38bdf8 (Basis, vorher #8fd9ea) + #7dd3fc (helle Variante,
vorher #c8f0f7). Gleiche Baby-/Himmelblau-Familie, aber deutlich mehr
Sättigung und weniger Helligkeit → sticht jetzt genauso klar hervor
wie die anderen drei Akzentfarben.
===================================================================== */
/* Profilbild-Auswahl in "Stimmen verwalten" (21.08.2026). Die Kacheln
selbst kommen aus main.css (.stimmen-avatar-*) — hier nur die Einbettung
in die Verwaltungs-Karte, etwas kleiner als auf der oeffentlichen Seite,
damit eine Einreichung nicht unnoetig hoch wird. */
.vw-stimme-avatar-block { margin-top: 1rem; padding-top: .9rem; border-top: 1px solid var(--border); }
.vw-stimme-avatar-wahl { grid-template-columns: repeat(auto-fill, minmax(46px, 1fr)); max-width: 520px; }
.vw-stimme-upload { display: block; margin-top: .8rem; color: var(--text-muted); }
.vw-stimme-upload input { display: block; margin-top: .3rem; }
#supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; }
body { position: relative; }
/* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift
deutlicher — nur ich seh das hier". Da fast die komplette Seite in
rem definiert ist, skaliert eine höhere Basis-Schriftgröße praktisch
alles proportional mit (Text UND viele Abstände/Paddings), ohne jede
einzelne Regel von Hand anfassen zu müssen. Nur px-Werte (Icons,
Avatare) skalieren NICHT automatisch mit — die werden unten gezielt
einzeln angehoben. */
html { font-size: 18.5px; }
/* Bugfix 20.08.2026 (Nutzer-Report per Screenshot-Vergleich: die Leiste
sieht auf der Verwaltungsseite größer aus als auf der Hauptseite,
obwohl main.css/main.js exakt identisch sind): der `rem`-Einheit oben
ist ein GLOBALER Hebel — sie erhöht auch alle Maße der gemeinsamen
Navigationsleiste um denselben Faktor (18.5/16 ≈ 16 % größer), obwohl
die Leiste selbst gar nicht Teil der "Jewelen-Tresor"-Vergrößerung sein
sollte. Dadurch brauchte dieselbe Leiste hier ~16 % mehr Breite als
überall sonst und lief bei genau der Fensterbreite über den Rand, bei
der sie auf jeder anderen Seite noch gut passte — kein Cache-Problem,
ein echter, bislang übersehener CSS-Bug. Fix: die Leiste bekommt hier
ihre Maße fest in Pixel zurück (exakt die Werte, die bei der normalen
16px-Basis herauskämen) statt in `rem`, dadurch bleibt sie unabhängig
von der Basis-Schriftgröße dieser Seite exakt gleich groß wie überall. */
header.site-header .nav-bar { gap: 32px; padding: 14.4px 0; }
header.site-header .brand { gap: 10.4px; font-size: 21.12px; }
header.site-header .brand small { font-size: 11.2px; }
header.site-header .nav-links { gap: 11.2px; }
header.site-header .nav-group > button { font-size: 18.24px; gap: 4.8px; padding: 8px 8.8px; }
header.site-header .nav-flat { font-size: 18.24px; padding: 8px 8.8px; }
header.site-header .nav-dropdown { padding: 7.2px; }
header.site-header .nav-dropdown a { padding: 8.8px 12px; font-size: 16.96px; }
header.site-header .sprach-schalter > button { font-size: 17.28px; gap: 8px; padding: 7.2px 17.6px 7.2px 9.6px; }
header.site-header .nav-cta { padding: 9.6px 21.6px; font-size: 15.2px; }
header.site-header .nav-cta-soon { gap: 3.52px; margin-left: 8px; padding: 2.4px 8px; font-size: 10.24px; }
header.site-header .nav-toggle { padding: 8px 11.2px; }
/* ---------- Durchgehende Juwelen-Aurora (nicht nur beim Login) ---------- */
.vw-aurora { position: fixed; inset: -10%; z-index: -2; pointer-events: none; filter: blur(2px); }
.vw-aurora span { position: absolute; width: 44vw; height: 44vw; border-radius: 50%; filter: blur(85px); opacity: .3; mix-blend-mode: screen; }
.vw-aurora .g1 { left: -10%; top: -8%; background: radial-gradient(circle, rgba(56,189,248,.55), transparent 70%); animation: vw-drift 26s ease-in-out infinite; }
.vw-aurora .g2 { right: -12%; top: 16%; background: radial-gradient(circle, rgba(139,92,246,.42), transparent 70%); animation: vw-drift 32s ease-in-out infinite reverse; }
.vw-aurora .g3 { left: 22%; bottom: -18%; background: radial-gradient(circle, rgba(52,211,153,.3), transparent 70%); animation: vw-drift 38s ease-in-out infinite; }
.vw-aurora .g4 { right: 8%; bottom: -12%; background: radial-gradient(circle, rgba(255,111,165,.28), transparent 70%); animation: vw-drift 34s ease-in-out infinite reverse; }
@keyframes vw-drift { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(5vw,4vh) scale(1.12); } }
@media (prefers-reduced-motion: reduce) { .vw-aurora span { animation: none; } }
/* =====================================================================
Logo-Wappen-Hintergrund (Nutzer-Wunsch 04.08.2026, 3. Runde: "ich will
auf dieser Seite auch mein Logo als Hintergrund, so richtig ultra
speziell, überrasch mich"). Zwei Ebenen:
1) Ein riesiges, sehr dezentes "Siegel" — das Logo mittig hinter der
gesamten Seite, langsam atmend, mit Gold-Glow und weichem
Rundausblenden zum Rand hin (mask-image), damit es wie ein
eingeprägtes Wappen wirkt statt wie ein plumpes Wasserzeichen.
2) Ein feines, winzig gekacheltes Muster desselben Logos, schräg
gedreht, bei nur ~2% Deckkraft — dieselbe Textur wie ein
Sicherheits-/Tresordokument, verstärkt das "Privater Bereich"-
Gefühl, ohne jemals mit dem Text zu konkurrieren.
Beide Ebenen liegen HINTER der Aurora (noch niedrigerer z-index),
damit die Farbverläufe weiter obendrüber schimmern. */
.vw-logo-bg { position: fixed; inset: 0; z-index: -4; pointer-events: none; overflow: hidden; }
.vw-logo-emblem {
position: absolute; top: 50%; left: 50%; width: min(1200px, 150vw); height: min(1200px, 150vw);
transform: translate(-50%, -50%);
background-image: url('assets/img/logo-dogfather-transparent.png');
background-size: contain; background-repeat: no-repeat; background-position: center;
opacity: .05;
filter: drop-shadow(0 0 120px rgba(56,189,248,.55)) drop-shadow(0 0 60px rgba(139,92,246,.25));
mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
-webkit-mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
animation: vw-emblem-atem 9s ease-in-out infinite;
}
@keyframes vw-emblem-atem {
0%, 100% { opacity: .045; transform: translate(-50%, -50%) scale(1); }
50% { opacity: .085; transform: translate(-50%, -50%) scale(1.035); }
}
.vw-logo-tile {
position: absolute; inset: -25%;
background-image: url('assets/img/logo-dogfather-transparent.png');
background-size: 150px 150px; background-repeat: repeat;
opacity: .02; transform: rotate(-14deg);
}
@media (prefers-reduced-motion: reduce) { .vw-logo-emblem { animation: none; } }
.vw-card { position: relative; overflow: hidden; border: 1.5px solid rgba(56,189,248,.35);
background: radial-gradient(circle at 18% 0%, rgba(56,189,248,.1), transparent 55%), linear-gradient(180deg, rgba(23,27,33,.92), rgba(14,16,20,.96)); }
/* Feines Foliekorn (dieselbe SVG-Turbulence wie .btn-holo im Hauptstyle)
für einen materiellen, "echten" Tresor-Oberflächen-Look statt platter
Flächen — sehr dezent (opacity), nur auf den großen Schimmer-Karten. */
.vw-noise::after {
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: .05; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
background-size: 160px 160px;
}
.vw-noise > * { position: relative; z-index: 1; }
/* Juwelen-Schimmer-Rand: läuft durch Gold → Amethyst → Rosé → Smaragd →
Gold, statt nur eintönigem Gold — der "andere Farben"-Wunsch. Immer
derselbe zweischichtige background-position-Trick, NIE rotieren. */
.vw-shine-card {
position: relative; isolation: isolate; overflow: hidden;
border-radius: 18px; border: 1.5px solid transparent;
background:
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.97)) padding-box,
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
background-size: 100% 100%, 340% 100%;
animation: vw-stat-shimmer 10s linear infinite;
box-shadow: 0 14px 34px -18px rgba(0,0,0,.75);
}
@keyframes vw-stat-shimmer { to { background-position: 0 0, -340% 0; } }
@media (prefers-reduced-motion: reduce) { .vw-shine-card { animation: none; } }
.vw-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .28rem .7rem; border-radius: 999px; font-size: .74rem; font-weight: 700; }
.vw-badge.active { background: rgba(52,211,153,.15); color: #5eeaa7; box-shadow: 0 0 12px rgba(52,211,153,.2); }
.vw-badge.pending { background: rgba(56,189,248,.15); color: #7dd3fc; }
.vw-badge.suspended, .vw-badge.expired { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-badge.cancelled { background: rgba(255,255,255,.08); color: var(--text-muted); }
.vw-badge.none { background: rgba(255,255,255,.06); color: var(--text-muted); }
.vw-back { display: inline-flex; align-items: center; gap: .4rem; cursor: pointer; color: #7dd3fc; font-size: .9rem; font-weight: 600; transition: gap .15s ease; }
.vw-back:hover { gap: .65rem; }
.vw-fehler { color: #ff9a9a; font-size: .88rem; margin-top: .6rem; display: none; }
.vw-fehler.zeige { display: block; }
.vw-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .6rem; display: none; }
.vw-erfolg.zeige { display: block; }
/* =====================================================================
Login-Gate: Tresor-/Sicherheits-Optik statt einer schlichten Karte.
===================================================================== */
#gate-container { position: relative; min-height: 70vh; display: flex; align-items: center; }
.vw-gate-wrap { max-width: 780px; }
.vw-gate-head { margin-bottom: 1.6rem; }
.vw-gate-grid {
display: flex; flex-wrap: wrap; gap: 1.3rem; justify-content: center; align-items: stretch;
}
.vw-gate-card {
/* 22.08.2026 Bugfix (Meldung: "auf dem Handy sehe ich die Öffnen-Buttons gar nicht", Foto
zeigte nur einen millimeterschmalen Farbstreifen am unteren Kartenrand statt des ganzen
Knopfs): overflow:hidden war hier nie nötig — der Verlaufs-Rand-Trick (padding-box/
border-box-Hintergrund) respektiert border-radius von sich aus, ganz ohne Clipping, anders
als bei .vw-shine-card/.vw-noise, wo overflow:hidden ein ::after-Rausch-Pseudoelement
einfängt (das diese Karte gar nicht hat). overflow:hidden hat stattdessen auf manchen
Handys/Schriftgrößen-Einstellungen den Öffnen-Knopf mit-abgeschnitten, sobald der
tatsächliche Karteninhalt (z.B. bei vergrößerter System-Schrift) minimal höher wurde, als
die Karte beim ersten Layout-Durchlauf angenommen hatte. overflow:visible behebt das ohne
jeden optischen Nachteil (der Verlaufs-Rand bleibt exakt gleich, weil border-radius die
Hintergrund-Ebenen unabhängig von overflow immer schon rundet) und etwas mehr
Unterabstand als zusätzliches Sicherheitspolster, falls der Knopf durch größere
Systemschrift zweizeilig wird. */
position: relative; isolation: isolate; overflow: visible;
flex: 1 1 280px; max-width: 340px;
padding: 1.7rem 1.5rem 1.9rem;
border-radius: 20px; border: 1.5px solid transparent;
background:
linear-gradient(rgba(16,18,22,.95), rgba(9,10,13,.97)) padding-box,
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
background-size: 100% 100%, 340% 100%;
animation: vw-stat-shimmer 10s linear infinite;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(56,189,248,.35);
}
@media (prefers-reduced-motion: reduce) { .vw-gate-card { animation: none; } }
/* VanVans Kiste: bewusst eigene, femininere Farbwelt (Rosé/Lavendel/Gold
statt Babyblau/Lila) und ein Blüten- statt Schloss-Symbol — keine
bloße Umfärbung von Dogis Kiste, damit beide klar als "ihre eigene"
erkennbar sind. */
.vw-gate-card--vanvan {
background:
linear-gradient(rgba(24,15,20,.95), rgba(15,9,13,.97)) padding-box,
linear-gradient(110deg, #ff8fc6, #ffd1e8, #c084fc, #ffd76a, #ff8fc6) border-box;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(255,143,198,.4);
}
/* Nutzer-Wunsch 04.08.2026 (7. Runde): "richtig geil, mega speziell" —
die drei Platzhalter-Symbole (🔒 im großen Kreis oben, 🐺 bei Dogi,
🌸 bei VanVan) durch echte Fotos ersetzt: derselbe HasiDog-Schnitt wie
beim "Privater Bereich"-Wappen oben auf der Seite (gate-siegel-
hasidog.jpg), ein echtes Portrait für Dogis Kiste (vw-gate-dogi.jpg)
und ein echtes Portrait für VanVans Kiste (vw-gate-vanvan.jpg). Alle
drei als rundes Foto mit pulsierendem Farb-Ring statt Emoji, damit es
zum Rest der Schmuck-/Siegel-Optik dieser Seite passt. */
.vw-gate-schloss {
width: 72px; height: 72px; margin: 0 auto 1rem; border-radius: 50%;
background: radial-gradient(circle at 32% 28%, rgba(125,211,252,.35), rgba(56,189,248,.08) 70%),
url('assets/img/gate-siegel-hasidog.jpg') center / cover no-repeat;
border: 2px solid rgba(56,189,248,.6);
box-shadow: 0 0 26px rgba(56,189,248,.4), inset 0 0 12px rgba(255,255,255,.08);
animation: vw-schloss-puls 2.6s ease-in-out infinite;
}
@keyframes vw-schloss-puls {
0%, 100% { box-shadow: 0 0 20px rgba(56,189,248,.3), inset 0 0 12px rgba(255,255,255,.06); }
50% { box-shadow: 0 0 38px rgba(56,189,248,.6), inset 0 0 16px rgba(255,255,255,.12); }
}
.vw-gate-eyebrow {
display: block; text-align: center; font-size: .78rem; font-weight: 800; letter-spacing: .18em;
text-transform: uppercase; color: #ff9a9a; margin-bottom: .5rem;
}
.vw-gate-titel {
text-align: center; font-size: 1.6rem; font-weight: 800; margin: 0 0 .4rem;
background: linear-gradient(135deg, #fff, #7dd3fc 55%, #38bdf8);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.vw-gate-sub { text-align: center; font-size: .92rem; color: var(--text-muted); margin-bottom: 0; }
/* Kleine Kiste pro Person: eigenes Portrait-Foto + Name-Überschrift
statt Tier-Emoji, mit demselben Puls-Ring-Mechanismus wie oben, nur
in der jeweiligen Kartenfarbe. */
.vw-gate-card-icon {
display: block; width: 64px; height: 64px; margin: 0 auto .6rem; border-radius: 50%;
background-size: cover; background-position: center;
border: 2px solid rgba(56,189,248,.6);
box-shadow: 0 0 20px rgba(56,189,248,.4), inset 0 0 10px rgba(255,255,255,.08);
animation: vw-schloss-puls 2.6s ease-in-out infinite;
}
.vw-gate-card-icon--dogi { background-image: url('assets/img/vw-gate-dogi.jpg'); }
.vw-gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg'); }
@keyframes vw-schloss-puls-rose {
0%, 100% { box-shadow: 0 0 20px rgba(255,143,198,.35), inset 0 0 10px rgba(255,255,255,.06); }
50% { box-shadow: 0 0 36px rgba(255,143,198,.65), inset 0 0 14px rgba(255,255,255,.12); }
}
.vw-gate-card--vanvan .vw-gate-card-icon {
border-color: rgba(255,143,198,.65);
animation-name: vw-schloss-puls-rose;
}
.vw-gate-card-name {
text-align: center; font-size: 1.15rem; font-weight: 800; margin: 0 0 1rem; color: var(--text);
}
.vw-gate-card--vanvan .vw-gate-card-name { color: #ffd1e8; }
@media (prefers-reduced-motion: reduce) {
.vw-gate-schloss, .vw-gate-card-icon { animation: none; }
}
.vw-gate-card label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin-bottom: .4rem; }
.vw-gate-card input {
width: 100%; box-sizing: border-box; padding: .85rem 1rem; border-radius: 14px;
background: rgba(255,255,255,.04); border: 1.5px solid var(--border); color: var(--text);
font-size: 1.05rem; letter-spacing: .25em; text-align: center;
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-gate-card input:focus { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 4px rgba(56,189,248,.14); }
.vw-gate-card--vanvan input:focus { border-color: rgba(255,143,198,.7); box-shadow: 0 0 0 4px rgba(255,143,198,.16); }
/* Bugfix 04.08.2026: .btn-holo (main.css) ist für große, freistehende
Haupt-Knöpfe gedacht (großzügiges Padding, Großbuchstaben) — in einer
schmalen halben Login-Kiste (erst recht auf Mobilgeräten) brach der
Text dadurch auf zwei Zeilen um und wirkte riesig. Eigener, deutlich
kompakterer Knopf-Stil nur für diese Kisten. */
.vw-gate-card .btn-holo.vw-gate-btn {
width: 100%; justify-content: center; margin-top: 1.3rem;
padding: .6rem 1rem; font-size: .88rem; letter-spacing: .01em;
gap: .3rem;
}
.vw-gate-btn--vanvan {
background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%);
background-size: 340% 340%;
}
@media (max-width: 640px) {
.vw-gate-grid { flex-direction: column; align-items: center; }
.vw-gate-card { max-width: 340px; width: 100%; }
}
/* =====================================================================
Hero: persönliches "Command Center" statt generischer Seitenkopf.
===================================================================== */
.vw-hero-crest {
width: 116px; height: 116px; margin: 0 auto 1.2rem; border-radius: 50%;
background:
radial-gradient(circle at 32% 26%, rgba(125,211,252,.25), rgba(56,189,248,.08) 72%),
url('assets/img/hasidog-kopf-siegel.jpg') center / cover no-repeat;
border: 2.5px solid rgba(56,189,248,.6);
box-shadow: 0 0 34px rgba(56,189,248,.4), inset 0 0 18px rgba(0,0,0,.35), inset 0 0 0 3px rgba(10,12,16,.5);
animation: vw-schloss-puls 3.4s ease-in-out infinite;
}
.vw-hero-eyebrow {
display: flex; align-items: center; justify-content: center; gap: .4rem;
font-size: .78rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase;
color: #ff9a9a; margin-bottom: .6rem;
}
.vw-hero-titel {
font-size: clamp(1.9rem, 4vw, 2.7rem); font-weight: 900; text-align: center; margin: 0 0 .5rem;
background: linear-gradient(100deg, #fff, #7dd3fc 30%, #8b5cf6 50%, #ff6fa5 65%, #38bdf8 85%, #fff);
background-size: 260% 100%; -webkit-background-clip: text; background-clip: text; color: transparent;
animation: vw-titel-glanz 9s ease-in-out infinite;
}
@keyframes vw-titel-glanz { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
.vw-hero-gruss { text-align: center; font-size: 1.02rem; color: #7dd3fc; font-weight: 700; margin-bottom: .3rem; }
.vw-hero-sub { text-align: center; color: var(--text-muted); max-width: 560px; margin: 0 auto; }
@media (prefers-reduced-motion: reduce) { .vw-hero-crest, .vw-hero-titel { animation: none; } }
/* ---------- Session-Leiste ---------- */
.vw-session-bar {
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;
padding: .9rem 1.3rem; border-radius: 14px;
}
.vw-owner-badge {
display: inline-flex; align-items: center; gap: .5rem; padding: .4rem .9rem .4rem .6rem; border-radius: 999px;
background: radial-gradient(circle at 20% 30%, rgba(125,211,252,.18), rgba(56,189,248,.06));
border: 1.5px solid rgba(56,189,248,.4); font-weight: 800; font-size: .84rem; color: #7dd3fc;
box-shadow: 0 0 16px rgba(56,189,248,.2);
}
.vw-owner-badge .krone { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.6), rgba(56,189,248,.15) 70%); }
.vw-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #5eeaa7; box-shadow: 0 0 8px #5eeaa7; animation: vw-live-puls 2s ease-in-out infinite; }
@keyframes vw-live-puls { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.vw-uhrzeit { font-size: .82rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: .4rem; }
.vw-session-right { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
.vw-logout-btn { transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; }
.vw-logout-btn:hover { border-color: rgba(56,189,248,.6); box-shadow: 0 0 16px rgba(56,189,248,.2); transform: translateY(-1px); }
/* ---------- Kennzahlen-Übersicht: jede Kachel ihr eigener Edelstein ---------- */
.vw-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1.1rem; margin-bottom: 2rem; }
.vw-stat {
position: relative; isolation: isolate; overflow: hidden;
display: flex; flex-direction: column; align-items: flex-start; gap: .7rem;
border-radius: 18px; padding: 1.6rem 1.5rem 1.7rem;
border: 1.5px solid transparent;
background:
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.96)) padding-box,
linear-gradient(120deg, color-mix(in srgb, var(--sc) 55%, transparent), color-mix(in srgb, var(--sc) 90%, white 15%), color-mix(in srgb, var(--sc) 55%, transparent)) border-box;
background-size: 100% 100%, 280% 100%;
animation: vw-stat-shimmer-solo 6s ease-in-out infinite;
box-shadow: 0 14px 30px -18px rgba(0,0,0,.7);
transition: transform .2s ease, box-shadow .2s ease;
}
@keyframes vw-stat-shimmer-solo { 0%,100% { background-position: 0 0, 0% 50%; } 50% { background-position: 0 0, 100% 50%; } }
.vw-stat:hover { transform: translateY(-4px); box-shadow: 0 20px 40px -16px rgba(0,0,0,.75), 0 0 26px color-mix(in srgb, var(--sc) 40%, transparent); }
.vw-stat--violet { --sc: #8b5cf6; }
.vw-stat--emerald { --sc: #34d399; }
.vw-stat--blau { --sc: #38bdf8; }
.vw-stat--rose { --sc: #ff6fa5; }
.vw-stat-icon {
flex: none; width: 56px; height: 56px; border-radius: 50%; font-size: 1.55rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 30% 25%, color-mix(in srgb, var(--sc) 55%, white 20%), color-mix(in srgb, var(--sc) 15%, transparent) 72%);
border: 1.5px solid color-mix(in srgb, var(--sc) 55%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--sc) 35%, transparent), inset 0 0 10px rgba(255,255,255,.06);
}
.vw-stat .label { font-size: .8rem; font-weight: 800; color: var(--sc); margin-top: .2rem; text-transform: uppercase; letter-spacing: .06em; opacity: .95; }
.vw-stat .value { font-size: 1.65rem; font-weight: 800; line-height: 1.3; }
.vw-stat .value small { font-size: .76rem; font-weight: 600; color: var(--text-muted); text-transform: none; letter-spacing: 0; }
@media (prefers-reduced-motion: reduce) { .vw-stat { animation: none; } }
/* Fallback für Browser ohne color-mix() — bewusst konservativ, sieht
trotzdem gut aus statt kaputt (main.css unterstützt bereits moderne
CSS-Features, aber sicher ist sicher). */
@supports not (color: color-mix(in srgb, red, blue)) {
.vw-stat--violet .vw-stat-icon, .vw-stat--violet { border-color: rgba(139,92,246,.5); }
.vw-stat--emerald .vw-stat-icon, .vw-stat--emerald { border-color: rgba(52,211,153,.5); }
.vw-stat--rose .vw-stat-icon, .vw-stat--rose { border-color: rgba(255,111,165,.5); }
}
/* ---------- Toolbar / Filter / Suche ---------- */
.vw-toolbar { display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; padding: 1rem 1.2rem; margin-bottom: 1.4rem; }
.vw-toolbar label { font-size: .8rem; color: var(--text-muted); font-weight: 600; }
.vw-select-wrap { position: relative; }
.vw-select-btn {
display: flex; align-items: center; justify-content: space-between; gap: .8rem; width: 100%;
min-width: 190px; appearance: none; -webkit-appearance: none; padding: .6rem 2.2rem .6rem .9rem; border-radius: 10px;
background: rgba(255,255,255,.04); border: 1.5px solid rgba(56,189,248,.3); color: var(--text); font-weight: 600;
font-family: inherit; font-size: .95rem; text-align: left; cursor: pointer;
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-select-btn:hover, .vw-select-wrap.open .vw-select-btn { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 3px rgba(56,189,248,.12); }
.vw-select-wrap::after {
content: "▾"; position: absolute; right: .9rem; top: 50%; transform: translateY(-50%); pointer-events: none;
color: #7dd3fc; font-size: .8rem; transition: transform .15s ease;
}
.vw-select-wrap.open::after { transform: translateY(-50%) rotate(180deg); }
/* Eigenes Dropdown statt der nativen <select>-Liste — die wird vom
Browser/Betriebssystem gezeichnet und lässt sich mit CSS praktisch
nicht themen (siehe Kommentar oben bei der HTML-Struktur). */
/* Bugfix 04.08.2026: "position:fixed" führte auf dieser Seite (viele
verschachtelte "isolation:isolate"/"overflow:hidden"-Schimmer-Karten
im Seitenbaum) zu einem echten Compositing-Fehler des Browsers — die
Liste wurde beim Rendern teilweise dupliziert/an falscher Stelle
gemalt (reproduzierbar über mehrere unabhängige Testmethoden
verifiziert, es lag NICHT an Scroll-Handling oder der Animation).
Fix: "Portal"-Muster — die Liste wird per JS beim Öffnen direkt als
Kind von <body> angehängt (siehe initFilterDropdown) und dokument-
relativ absolut positioniert, komplett außerhalb jeder Karten-
Verschachtelung. Dasselbe Muster wird von den meisten UI-Bibliotheken
genau für solche Fälle verwendet. */
.vw-select-list {
position: absolute; z-index: 200; list-style: none;
margin: 0; width: max-content; max-width: 260px; padding: .4rem;
border-radius: 14px; border: 1.5px solid rgba(56,189,248,.4);
background: rgba(10,12,16,.98);
box-shadow: 0 14px 34px rgba(0,0,0,.6), 0 0 26px rgba(56,189,248,.22);
}
/* Bugfix-Muster aus früheren Runden (Spezifitätskonflikt [hidden] vs.
Autor-CSS) hier vorsorglich vermieden: .vw-select-list selbst setzt
kein "display", nur diese Regel — [hidden] gewinnt dadurch immer. */
.vw-select-list[hidden] { display: none; }
/* Bugfix 04.08.2026: eine CSS-"animation" mit fill-mode "both" blieb
nach dem Portal-Umzug (siehe initFilterDropdown) manchmal exakt im
Startzustand hängen (opacity:0) statt am Ende bei opacity:1 zu
landen — vermutlich, weil das Neu-Anhängen an <body> plus die
Scroll/Resize-Neupositionierung die Animation mehrfach neu
angestoßen hat. Einfacher & zuverlässiger ohne Animation. */
.vw-select-list li {
padding: .55rem .7rem; border-radius: 9px; font-size: .88rem; font-weight: 600; color: var(--text);
cursor: pointer; border-left: 3px solid transparent; transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.vw-select-list li:hover { background: rgba(56,189,248,.14); }
.vw-select-list li.selected { color: #7dd3fc; border-left-color: #38bdf8; background: rgba(56,189,248,.1); }
.vw-search-wrap { position: relative; flex: 1 1 220px; max-width: 320px; }
.vw-search-wrap input {
width: 100%; box-sizing: border-box; padding: .6rem .9rem .6rem 2.2rem; border-radius: 10px;
background: rgba(255,255,255,.04); border: 1.5px solid rgba(139,92,246,.35); color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-search-wrap input:focus { outline: none; border-color: rgba(139,92,246,.75); box-shadow: 0 0 0 3px rgba(139,92,246,.15); }
.vw-search-wrap::before { content: "🔍"; position: absolute; left: .75rem; top: 50%; transform: translateY(-50%); font-size: .85rem; opacity: .7; pointer-events: none; }
.vw-refresh-btn, .vw-export-btn {
display: inline-flex; align-items: center; gap: .5rem; padding: .58rem 1.1rem; border-radius: 999px;
background: rgba(56,189,248,.08); border: 1.5px solid rgba(56,189,248,.35); color: #7dd3fc; font-weight: 700; font-size: .85rem;
cursor: pointer; transition: background .15s ease, border-color .15s ease, transform .15s ease;
}
.vw-refresh-btn:hover, .vw-export-btn:hover { background: rgba(56,189,248,.16); border-color: rgba(56,189,248,.6); }
.vw-export-btn { background: rgba(52,211,153,.08); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
.vw-export-btn:hover { background: rgba(52,211,153,.16); border-color: rgba(52,211,153,.6); }
.vw-refresh-btn .ic { display: inline-block; transition: transform .5s ease; }
.vw-refresh-btn.lädt .ic { animation: vw-spin .6s linear infinite; }
@keyframes vw-spin { to { transform: rotate(360deg); } }
.vw-anzahl { margin-left: auto; font-size: .85rem; color: var(--text-muted); font-weight: 600; }
/* ---------- Supporter-Tabelle ---------- */
.vw-table-wrap { overflow-x: auto; border-radius: 18px; }
.vw-table { width: 100%; border-collapse: collapse; font-size: .92rem; min-width: 680px; }
.vw-table thead th {
position: sticky; top: 0; z-index: 1;
text-align: left; padding: .8rem .9rem; color: #7dd3fc; font-size: .72rem; text-transform: uppercase;
letter-spacing: .06em; font-weight: 800; background: rgba(10,15,22,.94); border-bottom: 1.5px solid rgba(56,189,248,.35);
}
.vw-table thead th.sortierbar { cursor: pointer; user-select: none; transition: color .15s ease; }
.vw-table thead th.sortierbar:hover { color: #fff; }
.vw-table thead th .pfeil { opacity: .5; margin-left: .25rem; font-size: .7rem; }
.vw-table thead th.aktiv-sort .pfeil { opacity: 1; color: #ff6fa5; }
.vw-table tbody td { padding: .75rem .9rem; border-bottom: 1px solid rgba(255,255,255,.05); vertical-align: middle; }
.vw-table tbody tr { transition: background .15s ease, transform .15s ease; }
.vw-table tbody tr:nth-child(even) { background: rgba(255,255,255,.015); }
.vw-table tr.klickbar { cursor: pointer; }
.vw-table tr.klickbar:hover { background: rgba(139,92,246,.09); }
.vw-avatar {
width: 40px; height: 40px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center; font-size: .92rem; font-weight: 800;
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.4), rgba(56,189,248,.12) 75%);
border: 1.5px solid rgba(56,189,248,.4); color: #7dd3fc;
}
.vw-name-cell { display: flex; align-items: center; gap: .7rem; font-weight: 700; }
.vw-mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .85rem; color: var(--text-muted); }
/* ---------- Detailansicht ---------- */
.vw-detail-head { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
.vw-avatar-lg {
width: 78px; height: 78px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center; font-size: 1.8rem; font-weight: 800;
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.5), rgba(56,189,248,.15) 75%);
border: 1.5px solid rgba(56,189,248,.5); color: #7dd3fc;
box-shadow: 0 0 20px rgba(56,189,248,.25);
}
.vw-detail-name { margin: 0 0 .35rem; font-size: 1.4rem; }
.vw-chip-row { display: flex; flex-wrap: wrap; gap: .5rem; }
.vw-chip {
display: inline-flex; align-items: center; gap: .35rem; padding: .3rem .7rem; border-radius: 999px;
font-size: .8rem; font-weight: 600; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text-muted);
}
.vw-chip.verifiziert { background: rgba(52,211,153,.1); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
.vw-chip.unverifiziert { background: rgba(217,74,92,.1); border-color: rgba(217,74,92,.35); color: #ff9a9a; }
.vw-copy-btn {
display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 50%;
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer;
font-size: .88rem; transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.vw-copy-btn:hover { background: rgba(56,189,248,.15); border-color: rgba(56,189,248,.5); color: #7dd3fc; }
.vw-copy-btn.kopiert { background: rgba(52,211,153,.15); border-color: rgba(52,211,153,.5); color: #5eeaa7; }
.vw-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .9rem 1.1rem; margin: 1.4rem 0 .4rem; }
.vw-detail-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .8; font-weight: 700; }
.vw-detail-value { font-size: 1.02rem; font-weight: 700; margin-top: .15rem; }
.vw-subh { font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .85; margin: 1.8rem 0 .8rem; display: flex; align-items: center; gap: .4rem; }
/* ---------- Mini-Fortschrittsbalken in der Tabelle ---------- */
.vw-mini-progress-wrap { display: flex; flex-direction: column; gap: .3rem; min-width: 130px; }
.vw-mini-progress { height: 9px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; border: 1px solid var(--border); }
.vw-mini-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8); box-shadow: 0 0 8px rgba(56,189,248,.4); transition: width .4s ease; }
.vw-mini-progress-label { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
/* ---------- Fortschritts-Kiste in der Detailansicht ---------- */
.vw-progress-box { padding: 1.6rem 1.6rem 1.8rem; margin-top: 1.6rem; }
.vw-progress-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .8rem; margin-bottom: 1rem; }
.vw-progress-zyklus {
display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .85rem; border-radius: 999px;
background: rgba(139,92,246,.12); border: 1.5px solid rgba(139,92,246,.4); color: #c4b0fb; font-weight: 800; font-size: .82rem;
}
.vw-progress-hero { font-size: 2.1rem; font-weight: 900; line-height: 1; margin-bottom: .3rem; }
.vw-progress-hero small { display: block; font-size: .8rem; font-weight: 600; color: var(--text-muted); margin-top: .3rem; }
.vw-progress-bar-big { height: 18px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); margin: 1rem 0 .5rem; }
.vw-progress-bar-big-fill {
height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8, #7dd3fc);
background-size: 220% 100%; animation: abo-progress-shine 5s ease-in-out infinite; box-shadow: 0 0 14px rgba(56,189,248,.45);
transition: width .5s ease;
}
@keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
/* Supporter-Abstimmungen (20.08.2026) */
.vw-poll-option { margin-bottom: .8rem; }
.vw-poll-option:last-child { margin-bottom: 0; }
.vw-poll-option-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
.vw-poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
.vw-poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5); box-shadow: 0 0 10px rgba(139,92,246,.4); transition: width .4s ease; }
.vw-progress-celebrate {
margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700;
background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc;
}
.vw-progress-milestones { display: flex; flex-wrap: wrap; gap: .8rem; margin-top: 1.4rem; }
.vw-progress-milestone {
flex: 1 1 140px; text-align: center; padding: 1rem .6rem .8rem; border-radius: 14px;
border: 1.5px solid var(--border); background: rgba(255,255,255,.02); transition: transform .18s ease, border-color .18s ease;
}
.vw-progress-milestone.erreicht { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
.vw-progress-milestone.naechste { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
.vw-progress-milestone:hover { transform: translateY(-3px); }
.vw-progress-milestone .med {
width: 56px; height: 56px; margin: 0 auto .4rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
background: radial-gradient(circle at 32% 26%, rgba(255,255,255,.12), transparent 65%); border: 1.5px solid var(--border);
}
.vw-progress-milestone.erreicht .med { border-color: #7dd3fc; background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.55), rgba(56,189,248,.12) 70%); }
.vw-progress-milestone.naechste .med { border-color: #38bdf8; background: radial-gradient(circle at 32% 26%, rgba(56,189,248,.4), transparent 70%); }
.vw-progress-milestone .m-monat { font-size: .74rem; color: var(--text-muted); }
.vw-progress-milestone .m-titel { font-weight: 800; font-size: .85rem; margin-top: .15rem; }
.vw-progress-milestone .m-status { font-size: .72rem; margin-top: .3rem; color: var(--text-muted); }
.vw-premium-card {
display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; padding: .9rem 1rem; border-radius: 14px;
background: rgba(255,255,255,.02); border: 1px solid var(--border); margin-bottom: .7rem;
}
.vw-premium-icon {
flex: none; width: 50px; height: 50px; border-radius: 50%; font-size: 1.35rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.4), rgba(56,189,248,.1) 70%);
border: 1.5px solid rgba(56,189,248,.4);
}
.vw-premium-info { flex: 1 1 180px; min-width: 0; }
.vw-premium-info strong { display: block; }
.vw-premium-row-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; }
.vw-premium-status {
display: inline-block; font-size: .74rem; font-weight: 700; padding: .22rem .6rem; border-radius: 999px;
color: var(--text-muted); background: rgba(255,255,255,.06); white-space: nowrap;
}
.vw-premium-status.status-verfuegbar { background: rgba(56,189,248,.15); color: #7dd3fc; }
.vw-premium-status.status-wird_geprueft, .vw-premium-status.status-wird_vorbereitet { background: rgba(255,111,165,.15); color: #ff9fc4; }
.vw-premium-status.status-versandbereit, .vw-premium-status.status-versendet { background: rgba(56,189,248,.15); color: #38bdf8; }
.vw-premium-status.status-zugestellt, .vw-premium-status.status-erhalten { background: rgba(52,211,153,.15); color: #5eeaa7; }
.vw-premium-status.status-problem, .vw-premium-status.status-ersatz_wird_vorbereitet { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-premium-card select {
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text); font-size: .85rem;
}
.vw-premium-card input[type="text"] {
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text);
font-size: .85rem; max-width: 160px;
}
.vw-pay-liste { display: flex; flex-direction: column; gap: .45rem; max-height: 280px; overflow-y: auto; padding-right: .3rem; }
.vw-pay-row {
display: flex; align-items: center; justify-content: space-between; gap: .8rem;
padding: .6rem .8rem; border-radius: 10px; background: rgba(255,255,255,.03); border: 1px solid var(--border); font-size: .87rem;
}
.vw-pay-datum { color: var(--text-muted); flex: 1 1 auto; }
.vw-pay-betrag { font-weight: 700; }
.vw-pay-status { font-size: .74rem; font-weight: 700; padding: .18rem .55rem; border-radius: 999px; white-space: nowrap; }
.vw-pay-status.status-completed { background: rgba(52,211,153,.15); color: #5eeaa7; }
.vw-pay-status.status-failed { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-pay-status.status-refunded, .vw-pay-status.status-reversed { background: rgba(56,189,248,.15); color: #38bdf8; }
.vw-leer { color: var(--text-muted); font-size: .9rem; padding: 1rem 0; text-align: center; }
/* =====================================================================
Feste Übersichts-Kisten auf der Hauptseite (Nutzer-Wunsch 04.08.2026:
"alle diese Fenster die nur dann erscheinen wenn ein Abonnent da ist,
sollen ein festes Fenster bekommen ... man muss draufklicken um zu
sehen wer was hat, wie weit ist"). Statt Fortschritt/Prämien/Zahlungen
nur je EINZELNEM Supporter in dessen Detailansicht zu zeigen, gibt es
jetzt drei permanent sichtbare, aufklappbare Kisten mit der Übersicht
über ALLE Supporter gleichzeitig — Kopfzeile zeigt die Kurzfassung
(Kennzahlen-Chips) immer, der Klick klappt die volle Liste auf.
===================================================================== */
.vw-overview-box { margin-bottom: 1.4rem; padding: 0; }
.vw-overview-head {
display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
padding: 1.1rem 1.4rem; cursor: pointer; user-select: none;
}
.vw-overview-title { display: flex; align-items: center; gap: .6rem; font-weight: 800; font-size: 1rem; flex: 1 1 auto; }
.vw-overview-summary { display: flex; gap: .5rem; flex-wrap: wrap; }
.vw-overview-chip {
padding: .32rem .75rem; border-radius: 999px; font-size: .76rem; font-weight: 700;
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap;
}
.vw-overview-chip.warn { background: rgba(56,189,248,.14); border-color: rgba(56,189,248,.4); color: #7dd3fc; }
.vw-overview-chip.gefahr { background: rgba(217,74,92,.14); border-color: rgba(217,74,92,.4); color: #ff9a9a; }
.vw-overview-toggle {
font-size: .82rem; color: #7dd3fc; display: flex; align-items: center; gap: .35rem; font-weight: 700; flex: none;
}
.vw-overview-toggle .pfeil { display: inline-block; transition: transform .2s ease; }
.vw-overview-box.offen .vw-overview-toggle .pfeil { transform: rotate(180deg); }
.vw-overview-body { display: none; padding: 0 1.4rem 1.4rem; border-top: 1px solid var(--border); max-height: 480px; overflow-y: auto; }
.vw-overview-box.offen .vw-overview-body { display: block; padding-top: 1.1rem; animation: vw-fade-in .25s ease-out; }
.vw-fortschritt-row {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .7rem .5rem; border-radius: 10px;
cursor: pointer; transition: background .15s ease;
}
.vw-fortschritt-row:hover { background: rgba(139,92,246,.08); }
.vw-fortschritt-row + .vw-fortschritt-row { border-top: 1px dashed var(--border); }
/* Sanftes Einblenden von Kennzahlen/Zeilen — fühlt sich "lebendig" an
statt dass alles einfach ruckartig erscheint. */
@keyframes vw-fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.vw-stat, .vw-table tbody tr { animation: vw-fade-in .35s ease-out backwards; }
.vw-table tbody tr:nth-child(1) { animation-delay: .02s; }
.vw-table tbody tr:nth-child(2) { animation-delay: .05s; }
.vw-table tbody tr:nth-child(3) { animation-delay: .08s; }
.vw-table tbody tr:nth-child(4) { animation-delay: .11s; }
.vw-table tbody tr:nth-child(5) { animation-delay: .14s; }
@media (prefers-reduced-motion: reduce) { .vw-stat, .vw-table tbody tr { animation: none; } }
/* Ankreuzfelder am Handy groß genug zum Tippen (21.08.2026). Die drei
"Aktiv"-Kästchen (Event des Jahres, Spezielles Live-Event, Team) sind
mit width/height als style="" direkt am Element gesetzt -- ein normaler
Stylesheet-Eintrag käme dagegen nicht an, deshalb hier ausnahmsweise
!important. Antippen der Beschriftung daneben funktionierte zwar schon
(alle drei stecken in einem <label>), das Kästchen selbst war mit 22px
aber deutlich unter einer bequemen Fingerfläche. */
@media (max-width: 760px) {
input[type="checkbox"] {
width: 26px !important;
height: 26px !important;
flex-shrink: 0;
}
label:has(> input[type="checkbox"]) { min-height: 44px; }
}
</style>
</head>
<body>
<div class="vw-logo-bg" aria-hidden="true">
<div class="vw-logo-tile"></div>
<div class="vw-logo-emblem"></div>
</div>
<div class="vw-aurora" aria-hidden="true"><span class="g1"></span><span class="g2"></span><span class="g3"></span><span class="g4"></span></div>
<div id="site-header"></div>
<main>
<section class="hero" id="page-hero" style="display:none;">
<div class="container">
<div class="vw-hero-crest" aria-hidden="true"></div>
<span class="vw-hero-eyebrow">🔒 Privater Bereich</span>
<h1 class="vw-hero-titel">DogiCrew-Verwaltung</h1>
<p class="vw-hero-gruss" id="hero-gruss">Willkommen zurück.</p>
<p class="vw-hero-sub">Supporter, Prämien und Kollektionen — alles auf einen Blick, nur für dich sichtbar.</p>
</div>
</section>
<!-- ===== Login-Gate =====
Nutzer-Wunsch 04.08.2026 (6. Runde): (1) der "Verwaltung öffnen"-Knopf
war auf schmalen Bildschirmen riesig (umgebrochen auf zwei Zeilen) —
jetzt ein deutlich kompakterer .vw-gate-btn-Stil statt des vollen
.btn-holo-Formats. (2) VanVan hatte bisher keine eigene Zeile/Kiste,
beide teilten sich ein einziges Eingabefeld — jetzt zwei getrennte,
gleichwertige Kisten nebeneinander (auf Mobilgeräten untereinander),
jede mit eigenem Feld + eigenem Knopf. Dogis Kiste behält die
bisherige Babyblau/Lila-Optik, VanVans Kiste bekommt bewusst eine
eigene, femininere Optik (Rosé/Lavendel/Gold, Blüten-Icon statt
Schloss) statt einer bloßen Farbkopie. Beide Formulare rufen
weiterhin denselben Login-Endpunkt auf — technisch unverändert,
nur die Zugangscodes entscheiden serverseitig, wer sich anmeldet. -->
<section id="gate-container">
<div class="container vw-gate-wrap">
<div class="vw-gate-head">
<div class="vw-gate-schloss" aria-hidden="true"></div>
<span class="vw-gate-eyebrow">Gesperrter Bereich</span>
<h1 class="vw-gate-titel">DogiCrew-Verwaltung</h1>
<p class="vw-gate-sub">Zugangscode erforderlich — gilt danach 24 Stunden.</p>
</div>
<div class="vw-gate-grid">
<form id="code-gate-dogi" class="vw-gate-card vw-gate-card--dogi" novalidate>
<span class="vw-gate-card-icon vw-gate-card-icon--dogi" aria-hidden="true"></span>
<h2 class="vw-gate-card-name">Dogi</h2>
<label for="code-input-dogi">Zugangscode</label>
<input id="code-input-dogi" name="code" type="password" autocomplete="off" required autofocus />
<button class="btn-holo vw-gate-btn" type="submit">
<span class="btn-holo-text">💙 Öffnen</span>
</button>
<p class="vw-fehler" id="gate-fehler-dogi"></p>
</form>
<form id="code-gate-vanvan" class="vw-gate-card vw-gate-card--vanvan" novalidate>
<span class="vw-gate-card-icon vw-gate-card-icon--vanvan" aria-hidden="true"></span>
<h2 class="vw-gate-card-name">VanVan</h2>
<label for="code-input-vanvan">Zugangscode</label>
<input id="code-input-vanvan" name="code" type="password" autocomplete="off" required />
<button class="btn-holo vw-gate-btn vw-gate-btn--vanvan" type="submit">
<span class="btn-holo-text">💗 Öffnen</span>
</button>
<p class="vw-fehler" id="gate-fehler-vanvan"></p>
</form>
</div>
</div>
</section>
<!-- ===== Session-Leiste ===== -->
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
<div class="container">
<div class="vw-session-bar vw-shine-card vw-noise">
<span class="vw-owner-badge" id="role-badge"><span class="krone">👑</span> Owner</span>
<div class="vw-session-right">
<span class="vw-uhrzeit"><span class="vw-live-dot" aria-hidden="true"></span> Aktualisiert: <strong id="letzte-aktualisierung">–</strong></span>
<button class="btn btn-outline vw-logout-btn" id="pwa-install-btn" type="button" style="display:none;">📲 Als eigene App installieren</button>
<button class="btn btn-outline vw-logout-btn" id="logout-btn" type="button">Abmelden</button>
</div>
</div>
</div>
</section>
<!-- ===== Kennzahlen-Übersicht ===== -->
<section class="section-tight" id="vw-stats-root" style="display:none;padding-top:0;">
<div class="container">
<div class="vw-stats">
<div class="vw-stat vw-stat--violet"><span class="vw-stat-icon" aria-hidden="true">👥</span><div><div class="label">Gesamt Supporter</div><div class="value" id="stat-gesamt">–</div></div></div>
<div class="vw-stat vw-stat--emerald"><span class="vw-stat-icon" aria-hidden="true">💚</span><div><div class="label">Aktive Abos</div><div class="value" id="stat-aktiv">–</div></div></div>
<div class="vw-stat vw-stat--blau"><span class="vw-stat-icon" aria-hidden="true">💰</span><div><div class="label">Monatsumsatz</div><div class="value" id="stat-umsatz">–</div></div></div>
<div class="vw-stat vw-stat--rose"><span class="vw-stat-icon" aria-hidden="true">✨</span><div><div class="label">Neu (30 Tage)</div><div class="value" id="stat-neu">–</div></div></div>
</div>
</div>
</section>
<!-- ===== "Event des Jahres" (Startseite) — 20.08.2026, Nutzer-Wunsch:
"will ich in der admin seite das selbst gestalten können, mit bild
und text und am besten auch einen link". Nur sichtbar mit
EVENTS_MANAGE-Berechtigung (Owner immer). Titel/Text werden bewusst
NUR auf Deutsch eingegeben — server-internal/lib/translate.js
übersetzt beim Speichern automatisch nach en/fr/pt (de-CH bekommt
denselben deutschen Text, siehe Kommentar dort: Dialekt lässt sich
nicht maschinell übersetzen). Zeigt IMMER genau 2 feste Slots, kein
wachsender Katalog. -->
<section class="section-tight" id="events-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">🏆 Event des Jahres</h2>
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
Erscheint auf der Startseite unter den 3 Welt-Kacheln. Titel/Text bitte auf Deutsch
eintippen — die anderen Sprachen werden beim Speichern automatisch übersetzt
(maschinell, daher nie ganz so gut wie eine echte Übersetzung).
</p>
<div class="grid grid-2" id="events-grid" style="gap:1.2rem;"></div>
</div>
</section>
<!-- ===== "Spezielles Live-Event" (Streamplan-Seite) — 20.08.2026,
Nutzer-Wunsch: "spezielle live events da auch eintragen ... so dass
da eine zweite uhr erscheint wo die zeit dann für dieses spezielle
event läuft." Anders als "Event des Jahres" hier ein echtes
Datum+Uhrzeit statt eines freien Textes -- daraus baut
streamplan.html einen echten Countdown. Nur sichtbar mit derselben
EVENTS_MANAGE-Berechtigung. -->
<section class="section-tight" id="special-event-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">⚡ Spezielles Live-Event</h2>
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
Erscheint als zweite Uhr auf der Streamplan-Seite, sobald aktiviert. Titel bitte auf
Deutsch eintippen, wird beim Speichern automatisch übersetzt.
</p>
<div class="card vw-card vw-shine-card vw-noise" style="max-width:520px;margin:0 auto;padding:1.5rem;">
<div class="field">
<label for="se-titel">Titel (Deutsch)</label>
<input type="text" id="se-titel" maxlength="120" placeholder="z. B. Großer Q&amp;A-Abend" />
</div>
<div class="field">
<label for="se-datum">Datum &amp; Uhrzeit</label>
<input type="datetime-local" id="se-datum" />
</div>
<div class="field">
<label for="se-link">Link (optional, muss mit https:// beginnen)</label>
<input type="url" id="se-link" placeholder="https://…" />
</div>
<label style="display:flex;align-items:center;gap:.6rem;margin:1rem 0;cursor:pointer;">
<input type="checkbox" id="se-aktiv" style="width:1.2rem;height:1.2rem;" />
<span>Aktiv — zweite Uhr auf der Streamplan-Seite sichtbar</span>
</label>
<p class="small muted" style="margin:-.4rem 0 1rem;">
Nur mit Titel + Datum in der Zukunft aktivierbar. Ist das Datum vorbei, verschwindet
die zweite Uhr automatisch wieder.
</p>
<button type="button" class="btn btn-primary" id="se-speichern" style="width:100%;">Speichern</button>
<p class="small" id="se-status" style="margin-top:.6rem;"></p>
</div>
</div>
</section>
<!-- ===== "Stimmen" verwalten (20.08.2026) — Nutzer-Wunsch: "die
ausgewählten über die verwaltungsseite auf die website hinzufügen".
Eingereichte Nachrichten von stimmen.html landen hier zur Prüfung,
werden NIE automatisch veröffentlicht. ===== -->
<section class="section-tight" id="testimonials-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">💬 Stimmen verwalten</h2>
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
Einreichungen von der Stimmen-Seite. Erst nach "Freigeben" erscheinen sie öffentlich.
</p>
<div id="testimonials-liste"></div>
</div>
</section>
<!-- ===== "Team verwalten" (21.08.2026) — Nutzer-Wunsch: "überall wo
Modis sind oder Scouts oder Manager, oder Creator, will ich dass ich
die easy über meine Verwaltungsseite hinzufüge und die automatisch
in der Website hinzugefügt werden ... auch mit Fotos und TikTok
Link". Ergänzt (überschreibt NICHT) die von Hand gepflegten
Einträge in assets/js/data-modis.js/data-scouts.js/data-creator.js/
data-manager.js -- neue Mitglieder erscheinen automatisch auf
team-modis.html/team-scouts.html/creator.html/manager.html UND auf
profil.html, exakt im selben Look wie die schon bestehenden (siehe
window.dogiTeamLaden() in main.js). Rolle/Bio/Zitat werden wie
"Event des Jahres" nur auf Deutsch eingetippt und beim Speichern
automatisch übersetzt. -->
<section class="section-tight" id="team-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">🧑‍🤝‍🧑 Team verwalten</h2>
<p class="small muted text-center" style="max-width:64ch;margin:0 auto 1.4rem;">
Modis, Scouts, Creator und Manager — neue Mitglieder erscheinen automatisch auf der
passenden Seite (mit eigenem Profil, Foto und TikTok-Button), genau wie die schon
bestehenden. Rolle/Vorstellung/Zitat bitte auf Deutsch eintippen, der Rest wird beim
Speichern automatisch übersetzt.
</p>
<!-- 21.08.2026 (Runde 2, Nutzer-Wunsch: "wenn ich eine änderung mache
ist es auch für jeden für die die schon da sind und die neuen"):
holt VanVan, Diene, Funny, Miss, Marina, Ghost, Patrick,
Bananenstift usw. einmalig aus den bestehenden data-*.js-Dateien
in die Datenbank -- danach sind sie hier genauso bearbeitbar wie
jedes neu angelegte Mitglied. Übernimmt die vorhandenen, von Hand
geschriebenen Übersetzungen 1:1 (übersetzt NICHT erneut
maschinell). Mehrfach klickbar, ohne Duplikate zu erzeugen --
schon importierte Personen werden dabei einfach übersprungen. -->
<div class="text-center" style="margin-bottom:1.6rem;">
<button type="button" class="btn btn-outline" id="team-import-btn">📥 Bestehende Mitglieder importieren</button>
<p class="small muted" id="team-import-status" style="margin-top:.5rem;"></p>
</div>
<div class="card vw-card vw-shine-card vw-noise" style="max-width:640px;margin:0 auto 2rem;padding:1.5rem;">
<h3 id="team-form-titel" style="margin-top:0;">Neues Mitglied</h3>
<input type="hidden" id="team-id" value="" />
<div class="field">
<label for="team-kategorie">Kategorie</label>
<select id="team-kategorie">
<option value="modi">Modi (Team Dogi)</option>
<option value="scout">Scout (Spicy Media)</option>
<option value="creator">Creator (Spicy Media)</option>
<option value="manager">Manager (Spicy Media)</option>
</select>
</div>
<div class="field">
<label for="team-name">Name</label>
<input type="text" id="team-name" maxlength="60" placeholder="z. B. Foxy" />
</div>
<div class="field">
<label for="team-rolle">Rolle (Deutsch)</label>
<input type="text" id="team-rolle" maxlength="120" placeholder="z. B. Scout (Manager) — Rechte Hand" />
</div>
<div style="margin-bottom:1rem;text-align:center;">
<img id="team-foto-vorschau" alt="" style="width:140px;height:140px;object-fit:cover;border-radius:50%;background:var(--bg-alt);display:none;" />
</div>
<div class="field">
<label for="team-foto-input">Foto (optional, max. 5 MB, JPG/PNG/WebP)</label>
<input type="file" id="team-foto-input" accept="image/jpeg,image/png,image/webp" />
</div>
<div class="field">
<label for="team-tiktok">TikTok (Benutzername oder Link, optional)</label>
<input type="text" id="team-tiktok" maxlength="200" placeholder="z. B. @foxy123 oder https://tiktok.com/@foxy123" />
</div>
<div class="field">
<label for="team-bio">Kurze Vorstellung (Deutsch, optional)</label>
<textarea id="team-bio" maxlength="800" rows="3"></textarea>
</div>
<div class="field">
<label for="team-zitat">Zitat/Motto (Deutsch, optional)</label>
<input type="text" id="team-zitat" maxlength="200" />
</div>
<!-- 21.08.2026 (Runde 2): dieselbe Feld-Tiefe wie die von Hand
gepflegten Profile (siehe data-modis.js: intro/bioHtml/
extraCta) -- eingeklappt, weil die meisten Mitglieder das
nicht brauchen (nur wenige bestehende Profile wie Diene/
Patrick/Bananenstift/Marina nutzen das überhaupt). -->
<details style="margin-bottom:1.2rem;">
<summary style="cursor:pointer;color:var(--accent);font-weight:600;">Erweitert (optional)</summary>
<div style="margin-top:1rem;">
<div class="field">
<label for="team-intro">Kurzer Intro-Text neben dem Foto (Deutsch, optional, HTML erlaubt)</label>
<textarea id="team-intro" maxlength="2000" rows="2" placeholder="Ersetzt/ergänzt die kurze Vorstellung oben direkt neben dem Foto."></textarea>
</div>
<div class="field">
<label for="team-bio-html">Ausführliche Vorstellung (Deutsch, optional, HTML erlaubt: &lt;p&gt;/&lt;h3&gt;/&lt;strong&gt;/&lt;ul&gt;&lt;li&gt;)</label>
<textarea id="team-bio-html" maxlength="6000" rows="6" placeholder="Erscheint als breiter Lesebereich unterhalb des Steckbriefs, wie bei Diene/Patrick/Bananenstift."></textarea>
</div>
<div class="grid grid-2" style="gap:.8rem;">
<div class="field">
<label for="team-extra-cta-href">Zweiter Button: Ziel (z. B. manager.html)</label>
<input type="text" id="team-extra-cta-href" maxlength="300" placeholder="z. B. manager.html" />
</div>
<div class="field">
<label for="team-extra-cta-label">Zweiter Button: Text (Deutsch)</label>
<input type="text" id="team-extra-cta-label" maxlength="60" placeholder="z. B. Zum Manager-Profil" />
</div>
</div>
<p class="small muted" style="margin:.3rem 0 0;">
Zweiter Button erscheint zusätzlich zu "Profil ansehen" auf der Karte (aktuell nur bei
Marina genutzt, Verlinkung zu manager.html).
</p>
</div>
</details>
<div id="team-rang-felder" style="display:none;">
<div class="grid grid-2" style="gap:.8rem;">
<div class="field">
<label for="team-rang">Rang (Pyramiden-Stufe)</label>
<input type="number" id="team-rang" min="1" max="99" />
</div>
<div class="field">
<label for="team-rang-label">Rang-Bezeichnung (Deutsch)</label>
<input type="text" id="team-rang-label" maxlength="40" placeholder="z. B. Rechte Hand" />
</div>
</div>
<p class="small muted" style="margin:.3rem 0 1rem;">
Bestimmt die Ebene in der Modi-Pyramide — ohne Rang landet die Person automatisch
ganz unten.
</p>
</div>
<div class="field">
<label for="team-reihenfolge">Reihenfolge (kleinere Zahl erscheint zuerst)</label>
<input type="number" id="team-reihenfolge" value="0" />
</div>
<label style="display:flex;align-items:center;gap:.6rem;margin:1rem 0;cursor:pointer;">
<input type="checkbox" id="team-aktiv" checked style="width:1.2rem;height:1.2rem;" />
<span>Aktiv — öffentlich sichtbar</span>
</label>
<div class="btn-row">
<button type="button" class="btn btn-primary" id="team-speichern" style="flex:1;">Speichern</button>
<button type="button" class="btn btn-outline" id="team-abbrechen" style="display:none;">Abbrechen</button>
</div>
<p class="small" id="team-status" style="margin-top:.6rem;"></p>
</div>
<div class="btn-row" style="justify-content:center;margin-bottom:1.4rem;" id="team-filter-bar">
<button type="button" class="filter-btn active" data-team-filter="alle">Alle</button>
<button type="button" class="filter-btn" data-team-filter="modi">Modi</button>
<button type="button" class="filter-btn" data-team-filter="scout">Scouts</button>
<button type="button" class="filter-btn" data-team-filter="creator">Creator</button>
<button type="button" class="filter-btn" data-team-filter="manager">Manager</button>
</div>
<div id="team-liste"></div>
</div>
</section>
<!-- ===== "Kategorien & Seitentitel" (21.08.2026, Nutzer-Wunsch: "ich
will ich titel und namen von kategorien und seiten ändern können in
der verwaltung"). Ändert die Kategorie-Namen (z.B. "Modi") und die
Überschrift der jeweiligen öffentlichen Seite -- wirkt sofort
überall, wo diese Kategorie auftaucht (siehe data-site-text-key in
team-modis.html/team-scouts.html/creator.html/manager.html sowie
hier in der Team-Verwaltung selbst). Leeres Feld = zurück zum
eingebauten Standardtext. Gleiche TEAM_MANAGE-Berechtigung wie die
Team-Verwaltung, weil beides zusammengehört. ===== -->
<section class="section-tight" id="site-texts-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">🏷️ Kategorien &amp; Seitentitel</h2>
<p class="small muted text-center" style="max-width:64ch;margin:0 auto 1.4rem;">
Ändert, wie die vier Team-Kategorien überall auf der Seite (und hier in der Verwaltung)
heißen. Deutsch eintippen, der Rest wird automatisch übersetzt. Feld leer lassen und
speichern setzt wieder auf den Standardtext zurück.
</p>
<div class="grid grid-2" id="site-texts-grid" style="gap:1.2rem;max-width:900px;margin:0 auto;"></div>
</div>
</section>
<!-- ===== Supporter-Liste ===== -->
<section class="section-tight" id="supp-root" style="display:none;padding-top:0;">
<div class="container" id="liste-ansicht">
<!-- ===== Feste Übersichts-Kisten: immer sichtbar, kein Anklicken
eines einzelnen Supporters nötig (Nutzer-Wunsch 04.08.2026). ===== -->
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-praemien">
<div class="vw-overview-head" data-toggle="box-praemien">
<span class="vw-overview-title">🎁 Prämien-Warteschlange</span>
<div class="vw-overview-summary" id="praemien-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
</div>
<div class="vw-overview-body" id="praemien-body"></div>
</div>
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-fortschritt">
<div class="vw-overview-head" data-toggle="box-fortschritt">
<span class="vw-overview-title">📈 Fortschritts-Übersicht</span>
<div class="vw-overview-summary" id="fortschritt-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
</div>
<div class="vw-overview-body" id="fortschritt-body"></div>
</div>
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-zahlungen">
<div class="vw-overview-head" data-toggle="box-zahlungen">
<span class="vw-overview-title">💳 Zahlungen-Übersicht</span>
<div class="vw-overview-summary" id="zahlungen-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
</div>
<div class="vw-overview-body" id="zahlungen-body"></div>
</div>
<!-- Supporter-Abstimmungen (20.08.2026) — "nächste Ausbaustufe" aus
Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20, eigenes
POLLS_MANAGE-Recht, siehe DogFather Website - Offene Punkte /
Supporter-Abo-System.md. Gleiches Kisten-Muster wie die 3 Boxen
oben, daher automatisch von der bestehenden
.vw-overview-head-Klick-Logik weiter unten miterfasst. -->
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-abstimmungen">
<div class="vw-overview-head" data-toggle="box-abstimmungen">
<span class="vw-overview-title">🗳️ Abstimmungen</span>
<div class="vw-overview-summary" id="abstimmungen-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
</div>
<div class="vw-overview-body" id="abstimmungen-body">
<div class="card" style="padding:1.2rem;margin-bottom:1.2rem;background:rgba(255,255,255,.03);">
<h3 class="vw-subh" style="margin-top:0;">Neue Abstimmung erstellen</h3>
<p class="small muted" style="margin:0 0 1rem;">
Frage und Optionen bitte auf Deutsch eintippen — die anderen Sprachen werden beim
Speichern automatisch übersetzt. Erscheint für jede aktive DogiCrew-Person in ihrem
Supporter-Bereich, bis sie abgestimmt hat oder du sie schließt.
</p>
<div class="field">
<label for="poll-frage">Frage</label>
<input type="text" id="poll-frage" maxlength="200" placeholder="z. B. Welches Format als nächstes?" />
</div>
<div id="poll-optionen-liste"></div>
<button type="button" class="vw-refresh-btn" id="poll-option-hinzufuegen" style="margin-top:.6rem;">+ Option hinzufügen</button>
<div style="margin-top:1.2rem;">
<button type="button" class="btn btn-primary" id="poll-erstellen-btn">Abstimmung starten</button>
</div>
<p class="small" id="poll-erstellen-status" style="margin-top:.6rem;"></p>
</div>
<div id="poll-liste"></div>
</div>
</div>
<!-- Nutzer-Wunsch 04.08.2026 (8. Runde): "dieses Fenster soll nicht
einfach kacke weiß aussehen, soll der ganzen Seite angepasst
sein" — das aufgeklappte Dropdown eines nativen <select> wird
vom Betriebssystem/Browser gezeichnet, NICHT von unserem CSS
(deshalb war es immer weiß, egal wie das <select> selbst
gestylt war — eine bekannte, browserübergreifende Grenze von
<select>, keine Design-Lücke). Lösung: komplett selbstgebautes
Dropdown (Knopf + eigene Liste), im selben dunklen/babyblauen
Look wie der Rest der Seite. Ersetzt das vorherige <select
id="filter-select">; die komplette Filter-Logik läuft jetzt
über die Variable currentFilter statt select.value. -->
<div class="vw-toolbar vw-shine-card vw-noise">
<label id="filter-label">Filter</label>
<div class="vw-select-wrap" id="filter-dropdown">
<button type="button" class="vw-select-btn" id="filter-btn" aria-haspopup="listbox" aria-expanded="false">
<span id="filter-btn-label">Alle</span>
</button>
<ul class="vw-select-list" id="filter-list" role="listbox" aria-labelledby="filter-label" hidden>
<li role="option" data-value="alle" aria-selected="true" class="selected">Alle</li>
<li role="option" data-value="aktiv" aria-selected="false">Aktiv</li>
<li role="option" data-value="gekuendigt" aria-selected="false">Gekündigt</li>
<li role="option" data-value="zahlung_fehlgeschlagen" aria-selected="false">Zahlung fehlgeschlagen</li>
<li role="option" data-value="monat_6" aria-selected="false">Ab Monat 6</li>
<li role="option" data-value="monat_12" aria-selected="false">Ab Monat 12</li>
<li role="option" data-value="monat_18" aria-selected="false">Ab Monat 18</li>
<li role="option" data-value="monat_24" aria-selected="false">Ab Monat 24</li>
<li role="option" data-value="monat_30" aria-selected="false">Ab Monat 30</li>
</ul>
</div>
<div class="vw-search-wrap">
<input type="search" id="such-input" placeholder="Suche nach Name, E-Mail, TikTok …" />
</div>
<button class="vw-refresh-btn" id="refresh-btn" type="button"><span class="ic" aria-hidden="true">🔄</span> Aktualisieren</button>
<button class="vw-export-btn" id="export-btn" type="button"><span aria-hidden="true">⬇️</span> CSV-Export</button>
<span class="vw-anzahl" id="anzahl-text"></span>
</div>
<div class="card vw-card vw-table-wrap">
<table class="vw-table">
<thead>
<tr>
<th></th>
<th class="sortierbar" data-sort="display_name">Name <span class="pfeil">↕</span></th>
<th>TikTok</th>
<th>E-Mail</th>
<th>Status</th>
<th class="sortierbar" data-sort="total_paid_months">Monate gesamt <span class="pfeil">↕</span></th>
<th>Fortschritt</th>
<th class="sortierbar" data-sort="created_at">Seit <span class="pfeil">↕</span></th>
</tr>
</thead>
<tbody id="supporter-tbody"></tbody>
</table>
</div>
<p class="vw-fehler" id="liste-fehler"></p>
</div>
<!-- ===== Supporter-Detail ===== -->
<div class="container" id="detail-ansicht" style="display:none;">
<p class="vw-back" id="zurueck-link">← Zurück zur Liste</p>
<div class="card vw-card vw-shine-card vw-noise" style="margin-top:1rem;padding:1.6rem 1.6rem 1.8rem;">
<div class="vw-detail-head">
<div class="vw-avatar-lg" id="detail-avatar" aria-hidden="true">–</div>
<div>
<h2 class="vw-detail-name" id="detail-name">–</h2>
<div class="vw-chip-row" id="detail-chips"></div>
</div>
</div>
<div class="vw-detail-grid" id="detail-grid">
<div><div class="vw-detail-label">Status</div><div class="vw-detail-value" id="detail-status">–</div></div>
<div><div class="vw-detail-label">Nächste Zahlung</div><div class="vw-detail-value" id="detail-naechste">–</div></div>
<div><div class="vw-detail-label">Bezahlte Monate</div><div class="vw-detail-value" id="detail-monate">–</div></div>
<div><div class="vw-detail-label">PayPal-Abo-ID</div><div class="vw-detail-value vw-mono" id="detail-paypal" style="font-size:.78rem;">–</div></div>
</div>
</div>
<!-- ===== Fortschritts-Kiste: wo steht diese Person im 30-Monats-
Zyklus (Nutzer-Wunsch 04.08.2026: "eine Kiste wo zeigt, wo die
Leute mit den Abos dran sind"). Dieselbe Zyklus-/Meilenstein-
Logik wie im Supporter-Bereich selbst. ===== -->
<div class="card vw-card vw-shine-card vw-noise vw-progress-box" id="detail-progress-box">
<div class="vw-progress-head">
<h3 class="vw-subh" style="margin:0;">📈 Fortschritt im Prämien-Zyklus</h3>
<span class="vw-progress-zyklus" id="detail-zyklus-badge">Zyklus 1</span>
</div>
<div class="vw-progress-hero" id="detail-progress-hero">0 <small>Monate im aktuellen Zyklus</small></div>
<div class="vw-progress-bar-big"><div class="vw-progress-bar-big-fill" id="detail-progress-fill" style="width:0%"></div></div>
<p class="small" id="detail-progress-text" style="color:var(--text-muted);">–</p>
<p class="vw-progress-celebrate" id="detail-progress-celebrate" style="display:none;"></p>
<div class="vw-progress-milestones" id="detail-progress-milestones"></div>
</div>
<div class="card vw-card vw-shine-card" style="margin-top:1rem;padding:1.6rem 1.6rem 1.8rem;">
<h3 class="vw-subh" style="margin-top:0;">🎁 Prämien</h3>
<div id="detail-premiums"></div>
<p class="vw-erfolg" id="premium-erfolg">Gespeichert.</p>
<p class="vw-fehler" id="premium-fehler"></p>
<h3 class="vw-subh">💳 Zahlungen (letzte 50)</h3>
<div id="detail-payments" class="vw-pay-liste"></div>
</div>
</div>
</section>
</main>
<div id="site-footer"></div>
<!-- 21.08.2026: für den "Bestehende Mitglieder importieren"-Knopf in der
Team-Verwaltung -- liest die von Hand gepflegten Profile aus diesen
Dateien aus und bietet an, sie 1:1 in die Datenbank zu übernehmen. -->
<script src="assets/js/data-modis.js?v=20260827a"></script>
<script src="assets/js/data-scouts.js?v=20260827a"></script>
<script src="assets/js/data-creator.js?v=20260827a"></script>
<script src="assets/js/data-manager.js?v=20260827a"></script>
<script src="assets/js/data-stimmen-avatare.js?v=20260827a"></script>
<script src="assets/js/main.js?v=20260827a"></script>
<script src="assets/js/admin-auth.js?v=20260827a"></script>
<script>
(function () {
const API_BASE_URL = "https://postfach.dogfather-universe.com";
/* =====================================================================
BILDER VOR DEM HOCHLADEN AUFBEREITEN
ANLASS (27.08.2026)
Das Bild "Event des Jahres" lag als PNG mit 2524 KB auf dem Server
und machte damit zwei Drittel der gesamten Startseite aus. Es wird
mit 246 bis 420 Punkten Breite angezeigt -- die Datei hat 1672.
PNG ist fuer ein Foto ausserdem das falsche Format; dieselbe
Aufnahme als JPEG waere rund ein Fuenfzehntel so gross.
Der Fehler passiert beim Hochladen: Ein Handy liefert Fotos in
voller Kameraaufloesung, und niemand denkt vorher ans Verkleinern.
Es ist auch nicht die Aufgabe dessen, der ein Bild aussucht.
WARUM IM BROWSER UND NICHT AUF DEM SERVER
Serverseitig braeuchte es eine Bildbibliothek (sharp o.ae.) --
eine neue Abhaengigkeit mit nativem Code, installiert in einem
Verzeichnis, an das ich nicht herankomme. Der Browser kann das
ohnehin: Canvas skaliert und kodiert seit jeher.
Nebeneffekt: Schon der Upload selbst wird kleiner. Wer vom Handy
aus ein 8-MB-Foto hochlaedt, wartet sonst am Mobilfunknetz.
⚠️ WAS BEWUSST NICHT ANGETASTET WIRD
Kleine PNGs bleiben PNG. Ein Logo mit durchsichtigem Hintergrund
wuerde als JPEG einen schwarzen Kasten bekommen -- JPEG kennt keine
Transparenz. Die Grenze liegt bei 400 KB: Darunter ist es
wahrscheinlich eine Grafik, darueber praktisch immer ein Foto.
===================================================================== */
const BILD_MAX_BREITE = 1600; // reicht fuer jede Anzeige auf dieser Seite
const BILD_QUALITAET = 0.86;
const PNG_GRENZE = 400 * 1024; // darunter: unangetastet lassen
async function bildVorbereiten(datei) {
try {
if (!datei || !datei.type.startsWith("image/")) return datei;
const istPng = datei.type === "image/png";
const bild = await new Promise((fertig, schief) => {
const b = new Image();
b.onload = () => fertig(b);
b.onerror = schief;
b.src = URL.createObjectURL(datei);
});
const zuBreit = bild.naturalWidth > BILD_MAX_BREITE;
const grossesPng = istPng && datei.size > PNG_GRENZE;
/* Nichts zu tun: kleines Bild, richtiges Format. Dann bleibt die
Datei unveraendert -- kein Qualitaetsverlust ohne Gegenwert. */
if (!zuBreit && !grossesPng) {
URL.revokeObjectURL(bild.src);
return datei;
}
const breite = Math.min(bild.naturalWidth, BILD_MAX_BREITE);
const hoehe = Math.round((bild.naturalHeight / bild.naturalWidth) * breite);
const flaeche = document.createElement("canvas");
flaeche.width = breite;
flaeche.height = hoehe;
const stift = flaeche.getContext("2d");
/* Ohne diese beiden Zeilen skaliert der Browser hart und die
Kanten franst aus. */
stift.imageSmoothingEnabled = true;
stift.imageSmoothingQuality = "high";
/* Grosse PNGs werden zu JPEG -- die haben keinen durchsichtigen
Hintergrund, sonst waeren sie nicht so gross. Sicherheitshalber
wird trotzdem weiss unterlegt, damit aus einer eventuellen
Transparenz kein schwarzer Kasten wird. */
if (grossesPng) {
stift.fillStyle = "#ffffff";
stift.fillRect(0, 0, breite, hoehe);
}
stift.drawImage(bild, 0, 0, breite, hoehe);
URL.revokeObjectURL(bild.src);
const typ = (grossesPng || datei.type === "image/jpeg") ? "image/jpeg" : datei.type;
const klotz = await new Promise((fertig) =>
flaeche.toBlob(fertig, typ, BILD_QUALITAET));
/* Wurde es NICHT kleiner, bleibt das Original. Bei bereits gut
komprimierten Dateien kann ein erneutes Kodieren sonst sogar
zulegen -- und Qualitaet kosten fuer nichts. */
if (!klotz || klotz.size >= datei.size) return datei;
const name = datei.name.replace(/\.[^.]+$/, "") +
(typ === "image/jpeg" ? ".jpg" : datei.name.slice(datei.name.lastIndexOf(".")));
return new File([klotz], name, { type: typ, lastModified: Date.now() });
} catch (e) {
/* Geht irgendetwas schief, wird das Original hochgeladen. Ein
fehlgeschlagenes Verkleinern darf niemals einen Upload
verhindern. */
return datei;
}
}
/* Nach aussen verfuegbar machen -- gleiches Vorgehen wie bei
window.vwBalkenSetzen im Webdesign-Bereich.
Grund ist die Pruefbarkeit: Die Funktion sitzt im gekapselten
Skriptbereich, ein Test erreicht sie sonst nicht. Und eine
Bildaufbereitung, die man nicht pruefen kann, prueft niemand --
dabei entscheidet sie darueber, ob ein 8-MB-Foto oder ein 200-KB-
Bild auf dem Server landet. */
window.bildVorbereiten = bildVorbereiten;
const REDEEM_LABELS = {
verfuegbar: "Verfügbar", eingeloest: "Eingelöst", wird_geprueft: "Wird geprüft", wird_vorbereitet: "Wird vorbereitet",
versandbereit: "Versandbereit", versendet: "Versendet", zugestellt: "Zugestellt", erhalten: "Erhalten",
problem: "Problem gemeldet", ersatz_wird_vorbereitet: "Ersatz wird vorbereitet",
};
const PREMIUM_ICONS = {
teddy_klein: "🧸", teddy_mittel: "🧸", teddy_gross: "🧸", tasse_karte: "☕", hoodie: "🎽",
};
const PREMIUM_TYPE_LABELS = {
teddy_klein: "Kleiner Hasen-Teddy", teddy_mittel: "Mittelgroßer Hasen-Teddy", teddy_gross: "Großer Hasen-Teddy",
tasse_karte: "Tasse & Autogrammkarte", hoodie: "Exklusiver Hoodie",
};
const STATUS_LABELS = {
active: "Aktiv", pending: "Zahlung ausstehend", suspended: "Zahlung fehlgeschlagen",
cancelled: "Gekündigt", expired: "Beendet", none: "Kein Abo",
};
const PAYMENT_STATUS_LABELS = { completed: "Erfolgreich", failed: "Fehlgeschlagen", refunded: "Erstattet", reversed: "Storniert" };
// Kompletter, ungefilterter Supporter-Bestand — wird EINMAL geladen und
// danach für Filter/Suche/Sortierung komplett im Browser verarbeitet
// (kein Server-Roundtrip pro Tastenanschlag, fühlt sich sofort an).
let alleSupporter = [];
let sortierung = { feld: "created_at", richtung: "desc" };
function fmtDatum(iso) {
if (!iso) return "–";
try { return new Date(iso).toLocaleDateString("de-DE"); } catch { return iso; }
}
function fehlerZeigen(el, msg) { el.textContent = msg; el.classList.add("zeige"); }
function fehlerVerstecken(el) { el.classList.remove("zeige"); }
function escapeHtml(text) {
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
function initialen(name) {
const teile = String(name || "?").trim().split(/\s+/).filter(Boolean);
if (!teile.length) return "?";
return (teile[0][0] + (teile[1]?.[0] || "")).toUpperCase();
}
function gruss(name) {
// Bugfix 04.08.2026: war fest auf "Dogi" verdrahtet — seit VanVan einen
// eigenen Zugang zu dieser Seite hat (siehe 03 Verwaltungs-Zugang),
// muss die Begrüßung den tatsächlich eingeloggten Namen zeigen statt
// immer "Dogi" anzuzeigen, egal wer sich anmeldet.
// Ziel ist .textContent (kein innerHTML) -> kein escapeHtml nötig, das
// würde &/</> nur unnötig als Text-Entities anzeigen statt sie sicher
// zu neutralisieren (textContent tut das ohnehin automatisch).
const n = name || "Dogi";
const h = new Date().getHours();
if (h < 5) return `Noch wach, ${n}?`;
if (h < 11) return `Guten Morgen, ${n}.`;
if (h < 18) return `Willkommen zurück, ${n}.`;
if (h < 23) return `Guten Abend, ${n}.`;
return `Noch wach, ${n}?`;
}
async function pruefeSession() {
const session = window.AdminAuth.getSession();
if (!session) { zeigeGate(); return; }
document.getElementById("page-hero").style.display = "";
document.getElementById("hero-gruss").textContent = gruss(session.isOwner ? "Dogi" : session.name);
document.getElementById("gate-container").style.display = "none";
document.getElementById("session-bar").style.display = "";
document.getElementById("vw-stats-root").style.display = "";
document.getElementById("supp-root").style.display = "";
const roleBadge = document.getElementById("role-badge");
roleBadge.innerHTML = `<span class="krone">👑</span> ${session.isOwner ? "Owner" : (session.roleName || "Eingeloggt")}`;
if (window.AdminAuth.sessionHasPermission(session, "EVENTS_MANAGE")) {
document.getElementById("events-root").style.display = "";
await ladeUndRenderEvents(session);
document.getElementById("special-event-root").style.display = "";
await ladeUndRenderSpecialEvent();
}
if (window.AdminAuth.sessionHasPermission(session, "TESTIMONIALS_MANAGE")) {
document.getElementById("testimonials-root").style.display = "";
await ladeUndRenderTestimonials();
}
if (window.AdminAuth.sessionHasPermission(session, "TEAM_MANAGE")) {
document.getElementById("team-root").style.display = "";
await ladeUndRenderTeam();
document.getElementById("site-texts-root").style.display = "";
await ladeUndRenderSiteTexte();
}
const pollsBox = document.getElementById("box-abstimmungen");
if (window.AdminAuth.sessionHasPermission(session, "POLLS_MANAGE")) {
pollsBox.style.display = "";
await ladeUndRenderPolls();
} else {
pollsBox.style.display = "none";
}
await ladeAlleSupporter();
}
function zeigeGate() {
document.getElementById("page-hero").style.display = "none";
document.getElementById("gate-container").style.display = "";
document.getElementById("session-bar").style.display = "none";
document.getElementById("vw-stats-root").style.display = "none";
document.getElementById("supp-root").style.display = "none";
document.getElementById("events-root").style.display = "none";
document.getElementById("special-event-root").style.display = "none";
document.getElementById("testimonials-root").style.display = "none";
document.getElementById("team-root").style.display = "none";
document.getElementById("site-texts-root").style.display = "none";
document.getElementById("box-abstimmungen").style.display = "none";
}
/* ===== "Event des Jahres" — Verwaltung (20.08.2026) =====
Lädt den aktuellen Stand über denselben öffentlichen Endpunkt, den auch
index.html benutzt (liefert bereits alle Sprachen inkl. "de", das reicht
zum Vorausfüllen des Formulars) und baut pro Slot eine eigene Karte mit
Bild-Upload + Formular. */
const EVENT_SLOTS = [1, 2];
function eventKarteHtml(slot) {
return `
<div class="card vw-card vw-shine-card vw-noise" style="padding:1.5rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.8rem;">
<h3 style="margin:0;">Event ${slot}</h3>
<span class="vw-overview-chip" id="event-${slot}-status-chip">Entwurf</span>
</div>
<div style="margin-bottom:1rem;">
<img id="event-${slot}-vorschau" alt="" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;background:var(--bg-alt);display:none;" />
</div>
<div class="field">
<label for="event-${slot}-bild-input">Bild (optional, max. 5 MB, JPG/PNG/WebP)</label>
<input type="file" id="event-${slot}-bild-input" accept="image/jpeg,image/png,image/webp" />
</div>
<div class="field">
<label for="event-${slot}-datum">Datum</label>
<input type="text" id="event-${slot}-datum" maxlength="80" placeholder="z. B. 7. März 2026" />
</div>
<div class="field">
<label for="event-${slot}-titel">Titel (Deutsch)</label>
<input type="text" id="event-${slot}-titel" maxlength="120" />
</div>
<div class="field">
<label for="event-${slot}-text">Text (Deutsch)</label>
<textarea id="event-${slot}-text" maxlength="400" rows="3"></textarea>
</div>
<div class="field">
<label for="event-${slot}-link">Link (optional, muss mit https:// beginnen)</label>
<input type="url" id="event-${slot}-link" placeholder="https://…" />
</div>
<label style="display:flex;align-items:center;gap:.6rem;margin:1rem 0;cursor:pointer;">
<input type="checkbox" id="event-${slot}-aktiv" style="width:1.2rem;height:1.2rem;" />
<span>Aktiv — auf der Startseite sichtbar</span>
</label>
<p class="small muted" style="margin:-.4rem 0 1rem;">
Ohne Häkchen bleibt die Kachel auf der Startseite unsichtbar (Entwurf), auch wenn hier
schon alles ausgefüllt ist — so kannst du in Ruhe vorbereiten und erst dann live schalten.
</p>
<button type="button" class="btn btn-primary" id="event-${slot}-speichern" style="width:100%;">Speichern</button>
<p class="small" id="event-${slot}-status" style="margin-top:.6rem;"></p>
</div>`;
}
async function ladeUndRenderEvents(session) {
const grid = document.getElementById("events-grid");
grid.innerHTML = EVENT_SLOTS.map(eventKarteHtml).join("");
let aktuelleDaten = [null, null];
try {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/events/list", {});
if (res.ok) aktuelleDaten = res.events;
} catch {
/* Formular bleibt leer, Speichern funktioniert trotzdem — kein Blocker. */
}
EVENT_SLOTS.forEach((slot, i) => {
const eintrag = aktuelleDaten[i];
const bildInput = document.getElementById(`event-${slot}-bild-input`);
const vorschau = document.getElementById(`event-${slot}-vorschau`);
const statusChip = document.getElementById(`event-${slot}-status-chip`);
let hochgeladenesBild = eintrag?.bild || null;
const aktualisiereStatusChip = (aktiv) => {
statusChip.textContent = aktiv ? "🟢 Live" : "⚪ Entwurf";
};
if (eintrag) {
document.getElementById(`event-${slot}-datum`).value = eintrag.datum || "";
document.getElementById(`event-${slot}-titel`).value = eintrag.titel?.de || "";
document.getElementById(`event-${slot}-text`).value = eintrag.text?.de || "";
document.getElementById(`event-${slot}-link`).value = eintrag.link || "";
document.getElementById(`event-${slot}-aktiv`).checked = !!eintrag.aktiv;
aktualisiereStatusChip(!!eintrag.aktiv);
if (eintrag.bild) {
vorschau.src = eintrag.bild;
vorschau.style.display = "";
}
} else {
aktualisiereStatusChip(false);
}
document.getElementById(`event-${slot}-aktiv`).addEventListener("change", (e) => aktualisiereStatusChip(e.target.checked));
// Bild wird SOFORT bei Auswahl hochgeladen (nicht erst beim
// "Speichern"-Klick) — einfacher nachvollziehbar für Dogi/VanVan: die
// Vorschau zeigt exakt, was hochgeladen wurde, bevor überhaupt
// gespeichert wird.
bildInput.addEventListener("change", async () => {
const datei = bildInput.files?.[0];
if (!datei) return;
const statusEl = document.getElementById(`event-${slot}-status`);
statusEl.textContent = "Bild wird hochgeladen …";
statusEl.style.color = "";
const form = new FormData();
form.append("image", await bildVorbereiten(datei));
const session2 = window.AdminAuth.getSession();
try {
const res = await fetch(`${API_BASE_URL}/admin/events/upload-image`, {
method: "POST",
headers: session2?.token ? { Authorization: `Bearer ${session2.token}` } : {},
body: form,
});
const data = await res.json();
if (!data.ok) {
statusEl.textContent = data.error || "Bild-Upload fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
hochgeladenesBild = data.url;
vorschau.src = data.url;
vorschau.style.display = "";
statusEl.textContent = "Bild hochgeladen — jetzt noch auf „Speichern“ klicken.";
} catch {
statusEl.textContent = "Bild-Upload fehlgeschlagen (keine Verbindung).";
statusEl.style.color = "#ff6b6b";
}
});
document.getElementById(`event-${slot}-speichern`).addEventListener("click", async () => {
const statusEl = document.getElementById(`event-${slot}-status`);
const titel = document.getElementById(`event-${slot}-titel`).value.trim();
const text = document.getElementById(`event-${slot}-text`).value.trim();
if (!titel || !text) {
statusEl.textContent = "Titel und Text sind Pflicht.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "Wird gespeichert und übersetzt …";
statusEl.style.color = "";
const aktiv = document.getElementById(`event-${slot}-aktiv`).checked;
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/events/save", {
slot,
datum: document.getElementById(`event-${slot}-datum`).value.trim(),
titel,
text,
link: document.getElementById(`event-${slot}-link`).value.trim(),
bild: hochgeladenesBild,
aktiv,
});
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
aktualisiereStatusChip(aktiv);
statusEl.textContent = aktiv ? "✅ Gespeichert — auf der Startseite live." : "✅ Als Entwurf gespeichert (noch nicht sichtbar).";
statusEl.style.color = "#baf7d6";
});
});
}
/* ===== Supporter-Abstimmungen (20.08.2026) =====
Formular mit dynamischer Optionsliste (mind. 2, max. 6), Übersetzung
passiert serverseitig beim Speichern (wie "Event des Jahres"). Liste
zeigt jede Abstimmung mit Live-Ergebnisbalken + Schließen/Wiedereröffnen/
Löschen — dieselbe Kisten-Optik wie die 3 Übersichts-Boxen oben. */
const POLL_STATUS_LABELS = { active: "Aktiv", closed: "Geschlossen" };
function pollOptionsHtml(anzahl) {
let html = "";
for (let i = 0; i < anzahl; i++) {
html += `<div class="field" style="margin-top:.6rem;" data-poll-option-feld>
<label>Option ${i + 1}</label>
<input type="text" class="poll-option-input" maxlength="100" />
</div>`;
}
return html;
}
function baueOptionsListe() {
document.getElementById("poll-optionen-liste").innerHTML = pollOptionsHtml(2);
}
baueOptionsListe();
document.getElementById("poll-option-hinzufuegen").addEventListener("click", () => {
const liste = document.getElementById("poll-optionen-liste");
const anzahl = liste.querySelectorAll("[data-poll-option-feld]").length;
if (anzahl >= 6) return;
const feld = document.createElement("div");
feld.className = "field";
feld.style.marginTop = ".6rem";
feld.setAttribute("data-poll-option-feld", "");
feld.innerHTML = `<label>Option ${anzahl + 1}</label><input type="text" class="poll-option-input" maxlength="100" />`;
liste.appendChild(feld);
});
document.getElementById("poll-erstellen-btn").addEventListener("click", async () => {
const statusEl = document.getElementById("poll-erstellen-status");
const frage = document.getElementById("poll-frage").value.trim();
const optionen = [...document.querySelectorAll(".poll-option-input")]
.map((el) => el.value.trim())
.filter(Boolean);
if (!frage) {
statusEl.textContent = "Bitte eine Frage eingeben.";
statusEl.style.color = "#ff6b6b";
return;
}
if (optionen.length < 2) {
statusEl.textContent = "Mindestens 2 Antwortoptionen eintragen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "Wird erstellt und übersetzt …";
statusEl.style.color = "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/create", { frage, optionen });
if (!res.ok) {
statusEl.textContent = res.error || "Erstellen fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Abstimmung ist live im Supporter-Bereich.";
statusEl.style.color = "#baf7d6";
document.getElementById("poll-frage").value = "";
baueOptionsListe();
await ladeUndRenderPolls();
});
function pollKarteHtml(poll) {
const gesamt = poll.totalVotes || 0;
const balken = poll.options
.map((opt, i) => {
const n = poll.counts?.[i] || 0;
const proz = gesamt ? Math.round((n / gesamt) * 100) : 0;
return `<div class="vw-poll-option">
<div class="vw-poll-option-head">
<span>${escapeHtml(opt.de)}</span>
<span class="small" style="color:var(--text-muted);">${n} × (${proz}%)</span>
</div>
<div class="vw-poll-bar-track"><div class="vw-poll-bar-fill" style="width:${proz}%;"></div></div>
</div>`;
})
.join("");
const istAktiv = poll.status === "active";
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-poll-id="${poll.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
<div>
<strong>${escapeHtml(poll.question.de)}</strong>
<div class="small" style="color:var(--text-muted);">${POLL_STATUS_LABELS[poll.status] || poll.status} · ${gesamt} Stimme${gesamt === 1 ? "" : "n"} · erstellt am ${fmtDatum(poll.createdAt)}</div>
</div>
<div class="btn-row" style="gap:.5rem;">
${istAktiv
? `<button type="button" class="vw-refresh-btn poll-schliessen-btn">Schließen</button>`
: `<button type="button" class="vw-refresh-btn poll-wiedereroeffnen-btn">Wiedereröffnen</button>`}
<button type="button" class="vw-refresh-btn poll-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
</div>
</div>
<div style="margin-top:1rem;">${balken}</div>
</div>`;
}
async function ladeUndRenderPolls() {
const summaryEl = document.getElementById("abstimmungen-summary");
const listeEl = document.getElementById("poll-liste");
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/list", {});
if (!res.ok) {
listeEl.innerHTML = `<p class="vw-leer">Abstimmungen konnten nicht geladen werden.</p>`;
return;
}
const polls = res.polls || [];
const aktiveAnzahl = polls.filter((p) => p.status === "active").length;
summaryEl.innerHTML = polls.length
? `<span class="vw-overview-chip">${aktiveAnzahl} aktiv</span><span class="vw-overview-chip">${polls.length} gesamt</span>`
: "";
listeEl.innerHTML = polls.length
? polls.map(pollKarteHtml).join("")
: `<p class="vw-leer">Noch keine Abstimmung erstellt.</p>`;
listeEl.querySelectorAll(".poll-schliessen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/close", { pollId });
await ladeUndRenderPolls();
});
});
listeEl.querySelectorAll(".poll-wiedereroeffnen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/reopen", { pollId });
await ladeUndRenderPolls();
});
});
listeEl.querySelectorAll(".poll-loeschen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Diese Abstimmung inkl. aller Stimmen unwiderruflich löschen?")) return;
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/delete", { pollId });
await ladeUndRenderPolls();
});
});
}
/* ===== "Spezielles Live-Event" — Verwaltung (20.08.2026) =====
Datum+Uhrzeit kommt vom Server als UTC-ISO-String, das <input
type="datetime-local"> braucht aber "YYYY-MM-DDTHH:mm" in der
LOKALEN Zeitzone der Person, die gerade in der Verwaltung sitzt —
deshalb hier von Hand umgerechnet statt toISOString() (das würde UTC
zurückgeben und beim erneuten Öffnen eine falsche Uhrzeit anzeigen). */
function isoZuLokalemDatetimeLocal(iso) {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function ladeUndRenderSpecialEvent() {
const statusEl = document.getElementById("se-status");
let eintrag = null;
try {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/special-event", {});
if (res.ok) eintrag = res.event;
} catch {
/* Formular bleibt leer, Speichern funktioniert trotzdem. */
}
if (eintrag) {
document.getElementById("se-titel").value = eintrag.titel?.de || "";
document.getElementById("se-datum").value = isoZuLokalemDatetimeLocal(eintrag.zieldatum);
document.getElementById("se-link").value = eintrag.link || "";
document.getElementById("se-aktiv").checked = !!eintrag.aktiv;
}
document.getElementById("se-speichern").addEventListener("click", async () => {
statusEl.textContent = "";
const titel = document.getElementById("se-titel").value.trim();
const datumRoh = document.getElementById("se-datum").value; // "YYYY-MM-DDTHH:mm", lokale Zeit
if (!titel || !datumRoh) {
statusEl.textContent = "Titel und Datum sind Pflicht.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "Wird gespeichert und übersetzt …";
statusEl.style.color = "";
// new Date("YYYY-MM-DDTHH:mm") interpretiert das als lokale Zeit dieses
// Browsers -- .toISOString() macht daraus den korrekten UTC-Zeitpunkt,
// den jede besuchende Person später in IHRER eigenen Ortszeit sieht.
const zieldatum = new Date(datumRoh).toISOString();
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/special-event/save", {
titel,
zieldatum,
link: document.getElementById("se-link").value.trim(),
aktiv: document.getElementById("se-aktiv").checked,
});
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = res.event.aktiv
? "✅ Gespeichert — zweite Uhr ist live auf der Streamplan-Seite."
: "✅ Als Entwurf gespeichert (noch nicht sichtbar).";
statusEl.style.color = "#baf7d6";
});
}
/* ===== "Stimmen" — Verwaltung (20.08.2026) =====
Offene Einreichungen zuerst (siehe getTestimonialsAdmin-Sortierung),
jede Karte mit Freigeben/Ablehnen/Löschen — je nach aktuellem Status
stehen nur die sinnvollen Aktionen zur Verfügung. */
const TESTIMONIAL_STATUS_LABELS = { eingereicht: "🟡 Neu", freigegeben: "🟢 Freigegeben", abgelehnt: "⚪ Abgelehnt" };
/* Profilbild-Auswahl je Einreichung (21.08.2026, Nutzer-Wunsch: "ich soll
auch paar geile bilder zum aussuchen haben als profilbild"). Nutzt
dieselbe Liste wie die öffentliche Seite (data-stimmen-avatare.js), damit
Dogi genau die Bilder sieht, die es auch wirklich gibt. Zusätzlich zu den
Auswahlbildern kann hier ein eigenes Foto hochgeladen werden — das darf
bewusst NUR die Verwaltung, nicht das öffentliche Formular. */
function avatarWahlHtml(t) {
const liste = window.STIMMEN_AVATARE || [];
const aktiv = (wert) => (t.avatar === wert ? " aktiv" : "");
const eigenes = t.avatar && /^https?:\/\//i.test(t.avatar);
return `
<div class="vw-stimme-avatar-block">
<span class="small" style="color:var(--text-muted);display:block;margin-bottom:.45rem;">Profilbild</span>
<div class="stimmen-avatar-wahl vw-stimme-avatar-wahl">
<button type="button" class="stimmen-avatar-kachel${aktiv(null) || (t.avatar ? "" : " aktiv")}" data-avatar="" title="Kein Bild (Anfangsbuchstabe)">
<span class="stimmen-avatar-keins" aria-hidden="true">✦</span>
</button>
${liste
.map(
(a) =>
`<button type="button" class="stimmen-avatar-kachel${aktiv(a.id)}" data-avatar="${a.id}" title="${escapeHtml(a.name)}"><img src="${a.datei}" alt="" loading="lazy" /></button>`
)
.join("")}
${
eigenes
? `<button type="button" class="stimmen-avatar-kachel aktiv" data-avatar="${escapeHtml(t.avatar)}" title="Eigenes Foto"><img src="${escapeHtml(t.avatar)}" alt="" loading="lazy" /></button>`
: ""
}
</div>
<label class="small vw-stimme-upload">
Eigenes Foto hochladen (max. 5 MB, JPG/PNG/WebP)
<input type="file" class="testimonial-foto-input" accept="image/jpeg,image/png,image/webp" />
</label>
<p class="small testimonial-avatar-status" style="margin:.35rem 0 0;"></p>
</div>`;
}
function testimonialKarteHtml(t) {
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-testimonial-id="${t.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
<div>
<strong>${escapeHtml(t.name)}</strong>${t.tiktokUsername ? ` <span class="small" style="color:var(--text-muted);">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
<div class="small" style="color:var(--text-muted);">${TESTIMONIAL_STATUS_LABELS[t.status] || t.status} · eingereicht am ${fmtDatum(t.createdAt)}</div>
</div>
<div class="btn-row" style="gap:.5rem;">
${t.status !== "freigegeben" ? `<button type="button" class="vw-refresh-btn testimonial-freigeben-btn">Freigeben</button>` : ""}
${t.status !== "abgelehnt" ? `<button type="button" class="vw-refresh-btn testimonial-ablehnen-btn">Ablehnen</button>` : ""}
<button type="button" class="vw-refresh-btn testimonial-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
</div>
</div>
<p style="margin:.8rem 0 0;">${escapeHtml(t.nachricht)}</p>
${avatarWahlHtml(t)}
</div>`;
}
async function ladeUndRenderTestimonials() {
const listeEl = document.getElementById("testimonials-liste");
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/list", {});
if (!res.ok) {
listeEl.innerHTML = `<p class="vw-leer">Stimmen konnten nicht geladen werden.</p>`;
return;
}
const liste = res.testimonials || [];
listeEl.innerHTML = liste.length ? liste.map(testimonialKarteHtml).join("") : `<p class="vw-leer">Noch keine Einreichungen.</p>`;
const aktion = async (btn, status) => {
const id = btn.closest("[data-testimonial-id]").dataset.testimonialId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/status", { id, status });
await ladeUndRenderTestimonials();
};
listeEl.querySelectorAll(".testimonial-freigeben-btn").forEach((btn) => btn.addEventListener("click", () => aktion(btn, "freigegeben")));
listeEl.querySelectorAll(".testimonial-ablehnen-btn").forEach((btn) => btn.addEventListener("click", () => aktion(btn, "abgelehnt")));
listeEl.querySelectorAll(".testimonial-loeschen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Diese Einreichung unwiderruflich löschen?")) return;
const id = btn.closest("[data-testimonial-id]").dataset.testimonialId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/delete", { id });
await ladeUndRenderTestimonials();
});
});
/* Profilbild anklicken = sofort speichern. Bewusst OHNE die ganze Liste
neu zu laden: sonst springt die Ansicht bei jedem Klick nach oben und
man verliert die Stelle, an der man gerade arbeitet. Stattdessen wird
nur die Markierung innerhalb dieser einen Karte umgesetzt. */
listeEl.querySelectorAll(".vw-stimme-avatar-wahl").forEach((wahl) => {
wahl.addEventListener("click", async (e) => {
const kachel = e.target.closest(".stimmen-avatar-kachel");
if (!kachel) return;
const karte = kachel.closest("[data-testimonial-id]");
const statusEl = karte.querySelector(".testimonial-avatar-status");
const avatar = kachel.dataset.avatar || "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/avatar", {
id: karte.dataset.testimonialId,
avatar,
});
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
wahl.querySelectorAll(".stimmen-avatar-kachel").forEach((b) => b.classList.remove("aktiv"));
kachel.classList.add("aktiv");
statusEl.textContent = "✅ Profilbild gespeichert.";
statusEl.style.color = "#baf7d6";
});
});
/* Eigenes Foto: wird sofort bei der Auswahl hochgeladen UND direkt als
Bild dieser Stimme gesetzt — ein zusätzlicher "Speichern"-Klick wäre
hier nur eine Stolperfalle (dieselbe Logik wie bei den Event-Bildern). */
listeEl.querySelectorAll(".testimonial-foto-input").forEach((input) => {
input.addEventListener("change", async () => {
const datei = input.files?.[0];
if (!datei) return;
const karte = input.closest("[data-testimonial-id]");
const statusEl = karte.querySelector(".testimonial-avatar-status");
statusEl.style.color = "";
statusEl.textContent = "Foto wird hochgeladen …";
const form = new FormData();
form.append("image", await bildVorbereiten(datei));
const session = window.AdminAuth.getSession();
try {
const res = await fetch(`${API_BASE_URL}/admin/testimonials/upload-avatar`, {
method: "POST",
headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {},
body: form,
});
const data = await res.json();
if (!data.ok) {
statusEl.textContent = data.error || "Upload fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
const gesetzt = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/avatar", {
id: karte.dataset.testimonialId,
avatar: data.url,
});
if (!gesetzt.ok) {
statusEl.textContent = gesetzt.error || "Foto konnte nicht gesetzt werden.";
statusEl.style.color = "#ff6b6b";
return;
}
await ladeUndRenderTestimonials();
} catch {
statusEl.textContent = "Upload fehlgeschlagen (keine Verbindung).";
statusEl.style.color = "#ff6b6b";
}
});
});
}
/* ===== "Team verwalten" — Modis/Scouts/Creator/Manager (21.08.2026) =====
Ein Formular für alle vier Kategorien (identische Felder), darunter
eine nach Kategorie filterbare Liste aller bereits angelegten
Mitglieder mit Bearbeiten/Löschen. Rolle/Bio/Zitat werden beim
Speichern serverseitig automatisch übersetzt (siehe routes/team.js) --
genau wie bei "Event des Jahres". Ein hochgeladenes Foto landet SOFORT
bei Auswahl auf dem Server (nicht erst bei "Speichern"), gleiches
Muster wie bei den Event-Bildern weiter oben. */
const TEAM_KATEGORIE_LABELS = { modi: "Modi", scout: "Scout", creator: "Creator", manager: "Manager" };
let teamHochgeladenesFoto = null;
let teamAktiverFilter = "alle";
function teamKarteHtml(m) {
const fotoHtml = m.foto
? `<img src="${m.foto}" alt="${escapeHtml(m.name)}" style="width:56px;height:56px;border-radius:50%;object-fit:cover;flex-shrink:0;">`
: `<span style="width:56px;height:56px;border-radius:50%;background:var(--bg-alt);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">${escapeHtml((m.name || "?").charAt(0).toUpperCase())}</span>`;
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.1rem 1.3rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;" data-team-id="${m.id}">
${fotoHtml}
<div style="flex:1;min-width:180px;">
<strong>${escapeHtml(m.name)}</strong>
<span class="vw-overview-chip">${TEAM_KATEGORIE_LABELS[m.kategorie] || m.kategorie}</span>
<span class="vw-overview-chip" style="${m.status === "pausiert" ? "opacity:.6;" : ""}">${m.status === "pausiert" ? "⚪ Pausiert" : "🟢 Aktiv"}</span>
<div class="small" style="color:var(--text-muted);">${escapeHtml(m.rolle?.de || "")}</div>
${m.tiktok ? `<a href="${escapeHtml(m.tiktok)}" target="_blank" rel="noopener" class="small">♪ TikTok</a>` : ""}
</div>
<div class="btn-row" style="gap:.5rem;">
<button type="button" class="vw-refresh-btn team-bearbeiten-btn">Bearbeiten</button>
<button type="button" class="vw-refresh-btn team-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
</div>
</div>`;
}
let teamAlleMitglieder = [];
function teamListeZeichnen() {
const listeEl = document.getElementById("team-liste");
const gefiltert = teamAktiverFilter === "alle" ? teamAlleMitglieder : teamAlleMitglieder.filter((m) => m.kategorie === teamAktiverFilter);
listeEl.innerHTML = gefiltert.length ? gefiltert.map(teamKarteHtml).join("") : `<p class="vw-leer">Noch keine Mitglieder in dieser Kategorie.</p>`;
listeEl.querySelectorAll(".team-bearbeiten-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.closest("[data-team-id]").dataset.teamId;
const m = teamAlleMitglieder.find((x) => x.id === id);
if (m) teamFormularBefuellen(m);
});
});
listeEl.querySelectorAll(".team-loeschen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.closest("[data-team-id]").dataset.teamId;
const m = teamAlleMitglieder.find((x) => x.id === id);
if (!confirm(`${m?.name || "Dieses Mitglied"} unwiderruflich löschen?`)) return;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/delete", { id });
await ladeUndRenderTeam();
});
});
}
async function ladeUndRenderTeam() {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/list", {});
teamAlleMitglieder = res.ok ? (res.mitglieder || []) : [];
teamListeZeichnen();
}
function teamFormularZuruecksetzen() {
document.getElementById("team-id").value = "";
document.getElementById("team-form-titel").textContent = "Neues Mitglied";
document.getElementById("team-kategorie").value = "modi";
document.getElementById("team-name").value = "";
document.getElementById("team-rolle").value = "";
document.getElementById("team-tiktok").value = "";
document.getElementById("team-bio").value = "";
document.getElementById("team-zitat").value = "";
document.getElementById("team-intro").value = "";
document.getElementById("team-bio-html").value = "";
document.getElementById("team-extra-cta-href").value = "";
document.getElementById("team-extra-cta-label").value = "";
document.getElementById("team-rang").value = "";
document.getElementById("team-rang-label").value = "";
document.getElementById("team-reihenfolge").value = "0";
document.getElementById("team-aktiv").checked = true;
document.getElementById("team-foto-vorschau").style.display = "none";
document.getElementById("team-foto-input").value = "";
document.getElementById("team-abbrechen").style.display = "none";
document.getElementById("team-status").textContent = "";
document.getElementById("team-intro").closest("details").open = false;
teamHochgeladenesFoto = null;
teamRangFelderAnpassen();
}
function teamFormularBefuellen(m) {
document.getElementById("team-id").value = m.id;
document.getElementById("team-form-titel").textContent = `„${m.name}“ bearbeiten`;
document.getElementById("team-kategorie").value = m.kategorie;
document.getElementById("team-name").value = m.name;
document.getElementById("team-rolle").value = m.rolle?.de || "";
document.getElementById("team-tiktok").value = m.tiktok || "";
document.getElementById("team-bio").value = m.bio?.de || "";
document.getElementById("team-zitat").value = m.zitat?.de || "";
document.getElementById("team-intro").value = m.intro?.de || "";
document.getElementById("team-bio-html").value = m.bioHtml?.de || "";
document.getElementById("team-extra-cta-href").value = m.extraCta?.href || "";
document.getElementById("team-extra-cta-label").value = m.extraCta?.label?.de || "";
document.getElementById("team-rang").value = m.rang || "";
document.getElementById("team-rang-label").value = m.rangLabel?.de || "";
document.getElementById("team-reihenfolge").value = m.reihenfolge ?? 0;
document.getElementById("team-aktiv").checked = m.status !== "pausiert";
// "Erweitert" automatisch aufklappen, wenn schon etwas drinsteht --
// sonst würde man leicht übersehen, dass z.B. Diene eine lange
// bioHtml-Vorstellung hat, weil sie eingeklappt verborgen bliebe.
document.getElementById("team-intro").closest("details").open = !!(m.intro?.de || m.bioHtml?.de || m.extraCta?.href);
teamHochgeladenesFoto = m.foto || null;
const vorschau = document.getElementById("team-foto-vorschau");
if (teamHochgeladenesFoto) {
vorschau.src = teamHochgeladenesFoto;
vorschau.style.display = "";
} else {
vorschau.style.display = "none";
}
document.getElementById("team-abbrechen").style.display = "";
document.getElementById("team-status").textContent = "";
teamRangFelderAnpassen();
document.getElementById("team-root").scrollIntoView({ behavior: "smooth", block: "start" });
}
function teamRangFelderAnpassen() {
const istModi = document.getElementById("team-kategorie").value === "modi";
document.getElementById("team-rang-felder").style.display = istModi ? "" : "none";
}
document.getElementById("team-kategorie").addEventListener("change", teamRangFelderAnpassen);
document.getElementById("team-abbrechen").addEventListener("click", teamFormularZuruecksetzen);
document.getElementById("team-foto-input").addEventListener("change", async () => {
const input = document.getElementById("team-foto-input");
const datei = input.files?.[0];
if (!datei) return;
const statusEl = document.getElementById("team-status");
statusEl.textContent = "Foto wird hochgeladen …";
statusEl.style.color = "";
const form = new FormData();
form.append("image", await bildVorbereiten(datei));
const session = window.AdminAuth.getSession();
try {
const res = await fetch(`${API_BASE_URL}/admin/team/upload-photo`, {
method: "POST",
headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {},
body: form,
});
const data = await res.json();
if (!data.ok) {
statusEl.textContent = data.error || "Foto-Upload fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
teamHochgeladenesFoto = data.url;
const vorschau = document.getElementById("team-foto-vorschau");
vorschau.src = data.url;
vorschau.style.display = "";
statusEl.textContent = "Foto hochgeladen — jetzt noch auf „Speichern“ klicken.";
} catch {
statusEl.textContent = "Foto-Upload fehlgeschlagen (keine Verbindung).";
statusEl.style.color = "#ff6b6b";
}
});
document.getElementById("team-speichern").addEventListener("click", async () => {
const statusEl = document.getElementById("team-status");
const name = document.getElementById("team-name").value.trim();
const rolle = document.getElementById("team-rolle").value.trim();
if (!name || !rolle) {
statusEl.textContent = "Name und Rolle sind Pflicht.";
statusEl.style.color = "#ff6b6b";
return;
}
const btn = document.getElementById("team-speichern");
btn.disabled = true;
statusEl.textContent = "Wird gespeichert und übersetzt …";
statusEl.style.color = "";
const id = document.getElementById("team-id").value || undefined;
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/save", {
id,
kategorie: document.getElementById("team-kategorie").value,
name,
rolle,
foto: teamHochgeladenesFoto,
tiktok: document.getElementById("team-tiktok").value.trim(),
bio: document.getElementById("team-bio").value.trim(),
zitat: document.getElementById("team-zitat").value.trim(),
intro: document.getElementById("team-intro").value.trim(),
bioHtml: document.getElementById("team-bio-html").value.trim(),
extraCtaHref: document.getElementById("team-extra-cta-href").value.trim(),
extraCtaLabel: document.getElementById("team-extra-cta-label").value.trim(),
rang: document.getElementById("team-rang").value,
rangLabel: document.getElementById("team-rang-label").value.trim(),
reihenfolge: document.getElementById("team-reihenfolge").value,
status: document.getElementById("team-aktiv").checked ? "aktiv" : "pausiert",
});
btn.disabled = false;
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
teamFormularZuruecksetzen();
await ladeUndRenderTeam();
// WICHTIG: erst NACH teamFormularZuruecksetzen() setzen -- die Funktion
// leert team-status als Teil des normalen Zurücksetzens, ein Erfolgs-
// text davor wäre also sofort wieder verschwunden, bevor er je zu sehen
// war (mit Playwright gefunden: der Status blieb nach dem Speichern
// immer leer).
document.getElementById("team-status").textContent = "✅ Gespeichert.";
document.getElementById("team-status").style.color = "#baf7d6";
});
document.getElementById("team-filter-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".filter-btn");
if (!btn) return;
teamAktiverFilter = btn.dataset.teamFilter;
document.querySelectorAll("#team-filter-bar .filter-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
teamListeZeichnen();
});
teamRangFelderAnpassen();
/* "Bestehende Mitglieder importieren" (21.08.2026, Runde 2, Nutzer-Wunsch:
"wenn ich eine änderung mache ist es auch für jeden für die die schon
da sind und die neuen"). Liest VanVan/Diene/Funny/Miss/Marina/Ghost
(window.MODIS, VanVan bewusst ausgenommen -- sie hat ihre eigene,
abweichend gebaute Hervorhebungs-Karte + eigene Seite vanvan.html,
passt nicht ins generische Schema), Patrick/Bananenstift
(window.SCOUTS) sowie window.CREATORS/MANAGERS (aktuell leer) aus und
schickt jede Person 1:1 an /admin/team/import. Übernimmt die
VORHANDENEN, von Hand geschriebenen Übersetzungen -- KEINE erneute
Maschinenübersetzung. Mehrfach klickbar (idempotent, siehe
routes/team.js): bereits importierte Personen werden serverseitig
einfach übersprungen, nie dupliziert. */
function zuSprachObjekt(feld) {
if (!feld) return null;
if (typeof feld === "string") return { de: feld, "de-CH": feld, en: feld, fr: feld, pt: feld };
if (typeof feld === "object" && feld.de) return feld;
return null;
}
function slugAusNameClient(name) {
return String(name || "")
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
document.getElementById("team-import-btn").addEventListener("click", async () => {
const btn = document.getElementById("team-import-btn");
const statusEl = document.getElementById("team-import-status");
btn.disabled = true;
statusEl.textContent = "Importiere …";
const quellen = [
...(window.MODIS || []).filter((p) => p.name !== "VanVan").map((p) => ({ p, kategorie: "modi" })),
...(window.SCOUTS || []).map((p) => ({ p, kategorie: "scout" })),
...(window.CREATORS || []).map((p) => ({ p, kategorie: "creator" })),
...(window.MANAGERS || []).map((p) => ({ p, kategorie: "manager" })),
];
let importiert = 0, uebersprungen = 0, fehler = 0;
for (const { p, kategorie } of quellen) {
const rolle = zuSprachObjekt(p.role);
if (!rolle) { fehler++; continue; } // Pflichtfeld fehlt -- diese Person überspringen, nicht alle abbrechen
const payload = {
kategorie,
name: p.name,
slug: p.slug || slugAusNameClient(p.name),
rolle,
foto: p.photo || p.avatar || "",
tiktok: p.social?.tiktok || null,
bio: zuSprachObjekt(p.bio),
intro: zuSprachObjekt(p.intro),
bioHtml: zuSprachObjekt(p.bioHtml),
zitat: zuSprachObjekt(p.quote),
rang: p.rank || null,
rangLabel: zuSprachObjekt(p.rankLabel),
extraCta: p.extraCta?.href ? { href: p.extraCta.href, label: zuSprachObjekt(p.extraCta.label) } : null,
};
try {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/import", payload);
if (res.ok && res.uebersprungen) uebersprungen++;
else if (res.ok) importiert++;
else fehler++;
} catch {
fehler++;
}
}
statusEl.textContent = `✅ ${importiert} importiert, ${uebersprungen} schon vorhanden` + (fehler ? `, ${fehler} übersprungen (fehlerhaft)` : "") + ".";
btn.disabled = false;
await ladeUndRenderTeam();
});
/* ===== "Kategorien & Seitentitel" (21.08.2026) =====
Ein Kärtchen pro Kategorie mit zwei Feldern (Kategorie-Name,
Seitentitel) -- speichert beide über denselben admin/site-texts/save-
Endpunkt wie zwei unabhängige Overrides. Aktualisiert nach dem
Speichern sofort die Kategorie-Badges in der Team-Liste, damit man den
Effekt nicht erst durch Neuladen sehen muss. */
const SITE_TEXT_KATEGORIEN = [
{ kat: "modi", label: "Modi", kategorieKey: "kategorie_modi", seitentitelKey: "seitentitel_modi", kategoriePlatzhalter: "Modi", seitentitelPlatzhalter: "Das Modi-Team" },
{ kat: "scout", label: "Scout", kategorieKey: "kategorie_scout", seitentitelKey: "seitentitel_scout", kategoriePlatzhalter: "Scouts", seitentitelPlatzhalter: "Mein Scout-Team" },
{ kat: "creator", label: "Creator", kategorieKey: "kategorie_creator", seitentitelKey: "seitentitel_creator", kategoriePlatzhalter: "Creator", seitentitelPlatzhalter: "Creator im Team" },
{ kat: "manager", label: "Manager", kategorieKey: "kategorie_manager", seitentitelKey: "seitentitel_manager", kategoriePlatzhalter: "Manager", seitentitelPlatzhalter: "Weitere Manager" },
];
function siteTextKarteHtml(eintrag) {
return `<div class="card vw-card vw-shine-card vw-noise" style="padding:1.3rem;" data-site-text-kat="${eintrag.kat}">
<h3 style="margin-top:0;">${eintrag.label}</h3>
<div class="field">
<label>Kategorie-Name</label>
<input type="text" class="site-text-kategorie-input" maxlength="40" placeholder="${eintrag.kategoriePlatzhalter}" />
</div>
<div class="field">
<label>Seitentitel</label>
<input type="text" class="site-text-seitentitel-input" maxlength="100" placeholder="${eintrag.seitentitelPlatzhalter}" />
</div>
<button type="button" class="btn btn-outline site-text-speichern-btn" style="width:100%;">Speichern</button>
<p class="small site-text-status" style="margin-top:.5rem;"></p>
</div>`;
}
async function teamKategorieLabelsAktualisieren(texteVorhanden) {
let texte = texteVorhanden;
if (!texte) {
try {
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
const data = await res.json();
texte = data.ok ? data.texte || {} : {};
} catch {
texte = {};
}
}
Object.keys(TEAM_KATEGORIE_LABELS).forEach((kat) => {
const override = texte[`kategorie_${kat}`]?.de;
if (override) TEAM_KATEGORIE_LABELS[kat] = override;
});
document.querySelectorAll("#team-filter-bar .filter-btn[data-team-filter]").forEach((btn) => {
const kat = btn.dataset.teamFilter;
if (TEAM_KATEGORIE_LABELS[kat]) btn.textContent = TEAM_KATEGORIE_LABELS[kat];
});
}
async function ladeUndRenderSiteTexte() {
const grid = document.getElementById("site-texts-grid");
grid.innerHTML = SITE_TEXT_KATEGORIEN.map(siteTextKarteHtml).join("");
let aktuelleTexte = {};
try {
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
const data = await res.json();
if (data.ok) aktuelleTexte = data.texte || {};
} catch {
/* Formular bleibt leer (= eingebauter Standardtext), Speichern funktioniert trotzdem. */
}
SITE_TEXT_KATEGORIEN.forEach((eintrag) => {
const karte = grid.querySelector(`[data-site-text-kat="${eintrag.kat}"]`);
karte.querySelector(".site-text-kategorie-input").value = aktuelleTexte[eintrag.kategorieKey]?.de || "";
karte.querySelector(".site-text-seitentitel-input").value = aktuelleTexte[eintrag.seitentitelKey]?.de || "";
karte.querySelector(".site-text-speichern-btn").addEventListener("click", async () => {
const statusEl = karte.querySelector(".site-text-status");
const btn = karte.querySelector(".site-text-speichern-btn");
btn.disabled = true;
statusEl.textContent = "Wird gespeichert …";
statusEl.style.color = "";
const kategorieText = karte.querySelector(".site-text-kategorie-input").value.trim();
const seitentitelText = karte.querySelector(".site-text-seitentitel-input").value.trim();
const [r1, r2] = await Promise.all([
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.kategorieKey, text: kategorieText }),
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.seitentitelKey, text: seitentitelText }),
]);
btn.disabled = false;
if (!r1.ok || !r2.ok) {
statusEl.textContent = r1.error || r2.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Gespeichert — wirkt sofort auf der Website.";
statusEl.style.color = "#baf7d6";
await teamKategorieLabelsAktualisieren();
teamListeZeichnen();
});
});
await teamKategorieLabelsAktualisieren(aktuelleTexte);
teamListeZeichnen();
}
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der
// eigenen Kiste.
function bindeGateFormular(formId, inputId, fehlerId) {
document.getElementById(formId).addEventListener("submit", async (e) => {
e.preventDefault();
const fehlerEl = document.getElementById(fehlerId);
fehlerVerstecken(fehlerEl);
const code = document.getElementById(inputId).value.trim();
if (!code) return;
const res = await window.AdminAuth.doLogin(API_BASE_URL, code);
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Zugangscode falsch.");
await pruefeSession();
});
}
bindeGateFormular("code-gate-dogi", "code-input-dogi", "gate-fehler-dogi");
bindeGateFormular("code-gate-vanvan", "code-input-vanvan", "gate-fehler-vanvan");
document.getElementById("logout-btn").addEventListener("click", async () => {
await window.AdminAuth.doLogout(API_BASE_URL);
zeigeGate();
});
function berechneStats(supporters) {
const gesamt = supporters.length;
const aktiv = supporters.filter((s) => s.sub_status === "active").length;
const umsatz = (aktiv * 4.99).toFixed(2).replace(".", ",");
const vor30Tagen = Date.now() - 30 * 86400000;
const neu = supporters.filter((s) => s.created_at && new Date(s.created_at).getTime() >= vor30Tagen).length;
document.getElementById("stat-gesamt").textContent = gesamt;
document.getElementById("stat-aktiv").textContent = aktiv;
document.getElementById("stat-umsatz").innerHTML = `${umsatz} € <small>geschätzt</small>`;
document.getElementById("stat-neu").textContent = neu;
}
// Zeigt pro Filter-Option direkt an, wie viele Supporter zutreffen
// ("Aktiv (3)" statt einfach nur "Aktiv") — man sieht auf einen Blick,
// was sich hinter jedem Filter verbirgt, ohne ihn erst anklicken zu müssen.
const FILTER_PRUEFUNG = {
alle: () => true,
aktiv: (r) => r.sub_status === "active",
gekuendigt: (r) => r.sub_status === "cancelled",
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
monat_6: (r) => (r.total_paid_months || 0) >= 6,
monat_12: (r) => (r.total_paid_months || 0) >= 12,
monat_18: (r) => (r.total_paid_months || 0) >= 18,
monat_24: (r) => (r.total_paid_months || 0) >= 24,
monat_30: (r) => (r.total_paid_months || 0) >= 30,
};
const FILTER_LABELS = {
alle: "Alle", aktiv: "Aktiv", gekuendigt: "Gekündigt",
zahlung_fehlgeschlagen: "Zahlung fehlgeschlagen",
monat_6: "Ab Monat 6", monat_12: "Ab Monat 12", monat_18: "Ab Monat 18",
monat_24: "Ab Monat 24", monat_30: "Ab Monat 30",
};
let currentFilter = "alle";
function aktualisiereFilterZahlen() {
document.querySelectorAll("#filter-list li").forEach((li) => {
const wert = li.dataset.value;
const pruef = FILTER_PRUEFUNG[wert];
const anzahl = pruef ? alleSupporter.filter(pruef).length : 0;
li.textContent = `${FILTER_LABELS[wert] || wert} (${anzahl})`;
if (wert === currentFilter) {
document.getElementById("filter-btn-label").textContent = li.textContent;
}
});
}
// Eigenes Dropdown (siehe HTML-Kommentar oben): Knopf öffnet/schließt
// die Liste, Klick auf einen Eintrag setzt currentFilter + rendert neu.
(function initFilterDropdown() {
const wrap = document.getElementById("filter-dropdown");
const btn = document.getElementById("filter-btn");
const list = document.getElementById("filter-list");
// "Portal"-Muster (siehe CSS-Kommentar bei .vw-select-list): die
// Liste wird beim allerersten Öffnen aus ihrer ursprünglichen Stelle
// im DOM herausgelöst und direkt an <body> gehängt — dadurch liegt
// sie außerhalb JEDER Karten-Verschachtelung/Stacking-Context-Kette
// und das Compositing-Problem kann gar nicht mehr auftreten. Position
// ist "absolute", also dokument-relativ (inkl. Scroll-Offset), nicht
// viewport-relativ wie bei "fixed" — deshalb + window.scrollX/Y.
let portalisiert = false;
function positioniere() {
if (!portalisiert) { document.body.appendChild(list); portalisiert = true; }
const rect = btn.getBoundingClientRect();
list.style.top = `${rect.bottom + window.scrollY + 8}px`;
list.style.left = `${rect.left + window.scrollX}px`;
list.style.minWidth = `${rect.width}px`;
}
function oeffnen() { positioniere(); wrap.classList.add("open"); list.hidden = false; btn.setAttribute("aria-expanded", "true"); }
function schliessen() { wrap.classList.remove("open"); list.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => (list.hidden ? oeffnen() : schliessen()));
// Bei Scroll/Resize passt die Position nicht mehr zum Knopf — live
// neu berechnen statt schließen.
window.addEventListener("scroll", () => { if (!list.hidden) positioniere(); }, { passive: true, capture: true });
window.addEventListener("resize", () => { if (!list.hidden) positioniere(); });
list.addEventListener("click", (e) => {
const li = e.target.closest("li[data-value]");
if (!li) return;
currentFilter = li.dataset.value;
document.getElementById("filter-btn-label").textContent = li.textContent;
list.querySelectorAll("li").forEach((el) => {
const gewaehlt = el === li;
el.classList.toggle("selected", gewaehlt);
el.setAttribute("aria-selected", String(gewaehlt));
});
schliessen();
renderTabelle();
});
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) schliessen(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") schliessen(); });
})();
function gefilterteSortierteListe() {
const filter = currentFilter;
const suchbegriff = document.getElementById("such-input").value.trim().toLowerCase();
let liste = alleSupporter.filter(FILTER_PRUEFUNG[filter] || FILTER_PRUEFUNG.alle);
if (suchbegriff) {
liste = liste.filter((s) =>
[s.display_name, s.email, s.tiktok_username].filter(Boolean).some((f) => f.toLowerCase().includes(suchbegriff))
);
}
const { feld, richtung } = sortierung;
liste = [...liste].sort((a, b) => {
let av = a[feld], bv = b[feld];
if (feld === "created_at") { av = av ? new Date(av).getTime() : 0; bv = bv ? new Date(bv).getTime() : 0; }
else if (feld === "total_paid_months") { av = av || 0; bv = bv || 0; }
else { av = String(av || "").toLowerCase(); bv = String(bv || "").toLowerCase(); }
if (av < bv) return richtung === "asc" ? -1 : 1;
if (av > bv) return richtung === "asc" ? 1 : -1;
return 0;
});
return liste;
}
async function ladeAlleSupporter() {
document.getElementById("detail-ansicht").style.display = "none";
document.getElementById("liste-ansicht").style.display = "";
const fehlerEl = document.getElementById("liste-fehler");
fehlerVerstecken(fehlerEl);
const refreshBtn = document.getElementById("refresh-btn");
refreshBtn.classList.add("lädt");
// Immer "alle" laden — Filtern/Suchen/Sortieren passiert danach
// komplett lokal, ohne erneuten Server-Roundtrip.
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/list", { filter: "alle" });
refreshBtn.classList.remove("lädt");
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Konnte Liste nicht laden.");
alleSupporter = res.supporters;
document.getElementById("letzte-aktualisierung").textContent = new Date().toLocaleTimeString("de-DE");
berechneStats(alleSupporter);
aktualisiereFilterZahlen();
renderTabelle();
renderFortschrittUebersicht();
await ladeUebersicht();
}
// ---------- Feste Übersichts-Kisten (Nutzer-Wunsch 04.08.2026) ----------
let uebersichtDaten = { premiums: [], payments: [] };
document.querySelectorAll(".vw-overview-head").forEach((head) => {
head.addEventListener("click", () => {
const box = document.getElementById(head.dataset.toggle);
const offen = box.classList.toggle("offen");
head.querySelector(".vw-overview-toggle").firstChild.textContent = offen ? "Verbergen " : "Anzeigen ";
});
});
async function ladeUebersicht() {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/overview", {});
if (!res.ok) return; // still zurückhaltend — die Hauptliste funktioniert auch ohne
uebersichtDaten = { premiums: res.premiums || [], payments: res.payments || [] };
renderPraemienUebersicht();
renderZahlungenUebersicht();
}
const PRAEMIEN_AKTIONSBEDUERFTIG = ["wird_geprueft", "wird_vorbereitet", "versandbereit", "problem", "ersatz_wird_vorbereitet"];
function bindePraemienSpeichernKnoepfe(container) {
container.querySelectorAll("[data-save]").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest("[data-premium-id]");
const redeemStatus = row.querySelector(".status-select").value;
const trackingNumber = row.querySelector(".tracking-input").value.trim();
btn.disabled = true;
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
});
btn.disabled = false;
if (!r.ok) { alert(r.error || "Speichern fehlgeschlagen."); return; }
btn.textContent = "✓ Gespeichert";
setTimeout(() => ladeUebersicht(), 700);
});
});
}
function renderPraemienUebersicht() {
const alle = uebersichtDaten.premiums;
const zaehler = {};
alle.forEach((p) => { zaehler[p.redeem_status] = (zaehler[p.redeem_status] || 0) + 1; });
const chips = PRAEMIEN_AKTIONSBEDUERFTIG.filter((s) => zaehler[s]).map(
(s) => `<span class="vw-overview-chip ${s === "problem" ? "gefahr" : "warn"}">${zaehler[s]} × ${REDEEM_LABELS[s]}</span>`
);
chips.push(`<span class="vw-overview-chip">${alle.length} Prämien gesamt</span>`);
document.getElementById("praemien-summary").innerHTML = chips.join("");
const body = document.getElementById("praemien-body");
if (!alle.length) {
body.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet — der erste Meilenstein ist nach 6 bezahlten Monaten erreicht.</p>`;
return;
}
body.innerHTML = alle
.map(
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
<div class="vw-premium-info">
<strong>${escapeHtml(p.display_name)}</strong> — ${PREMIUM_TYPE_LABELS[p.premium_type] || p.premium_type} (Monat ${p.milestone_month})
<div class="small" style="color:var(--text-muted);">${escapeHtml(p.email)}${p.tracking_number ? " · Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
</div>
<div class="vw-premium-row-fields">
<span class="vw-premium-status status-${p.redeem_status}">${REDEEM_LABELS[p.redeem_status] || p.redeem_status}</span>
<select class="status-select">
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
</select>
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
</div>
</div>`
)
.join("");
bindePraemienSpeichernKnoepfe(body);
}
const CYCLE_MEILENSTEINE = [6, 12, 18, 24, 30];
function naechsterMeilensteinMonat(monat) {
return CYCLE_MEILENSTEINE.find((m) => monat < m) ?? null;
}
function renderFortschrittUebersicht() {
const aktive = alleSupporter.filter((s) => s.sub_status === "active" && s.progress);
if (!aktive.length) {
document.getElementById("fortschritt-summary").innerHTML = `<span class="vw-overview-chip">Keine aktiven Abos</span>`;
document.getElementById("fortschritt-body").innerHTML = `<p class="vw-leer">Keine aktiven Abos vorhanden.</p>`;
return;
}
const avgMonat = Math.round(aktive.reduce((sum, s) => sum + s.progress.currentCycleMonth, 0) / aktive.length);
const balDran = aktive.filter((s) => {
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
return naechster !== null && naechster - s.progress.currentCycleMonth <= 2;
});
document.getElementById("fortschritt-summary").innerHTML = `
<span class="vw-overview-chip">Ø Monat ${avgMonat}/30</span>
${balDran.length ? `<span class="vw-overview-chip warn">${balDran.length} kurz vor Meilenstein</span>` : ""}
<span class="vw-overview-chip">${aktive.length} aktiv</span>`;
const sortiert = [...aktive].sort((a, b) => {
const na = naechsterMeilensteinMonat(a.progress.currentCycleMonth);
const nb = naechsterMeilensteinMonat(b.progress.currentCycleMonth);
const ra = na === null ? 999 : na - a.progress.currentCycleMonth;
const rb = nb === null ? 999 : nb - b.progress.currentCycleMonth;
return ra - rb;
});
const body = document.getElementById("fortschritt-body");
body.innerHTML = sortiert
.map((s) => {
const prozent = Math.round((s.progress.currentCycleMonth / 30) * 100);
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
const rest = naechster === null ? null : naechster - s.progress.currentCycleMonth;
return `<div class="vw-fortschritt-row" data-goto="${s.id}">
<span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span>
<span class="vw-name-cell" style="flex:0 1 160px;">${escapeHtml(s.display_name)}</span>
<div class="vw-mini-progress-wrap" style="flex:1 1 160px;">
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
<span class="vw-mini-progress-label">Zyklus ${s.progress.cycleNumber} · Monat ${s.progress.currentCycleMonth}/30</span>
</div>
<span class="small" style="color:var(--text-muted);white-space:nowrap;">${rest !== null ? `noch ${rest} Mon. bis Meilenstein` : "Zyklus komplett 🏆"}</span>
</div>`;
})
.join("");
body.querySelectorAll("[data-goto]").forEach((el) => el.addEventListener("click", () => zeigeDetail(el.dataset.goto)));
}
function renderZahlungenUebersicht() {
const alle = uebersichtDaten.payments;
const vor30Tagen = Date.now() - 30 * 86400000;
const letzte30 = alle.filter((p) => p.payment_status === "completed" && p.paid_at && new Date(p.paid_at).getTime() >= vor30Tagen);
const umsatz30 = letzte30.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0);
const fehlgeschlagen = alle.filter((p) => p.payment_status === "failed").length;
document.getElementById("zahlungen-summary").innerHTML = `
<span class="vw-overview-chip">${umsatz30.toFixed(2).replace(".", ",")} € (30 Tage)</span>
${fehlgeschlagen ? `<span class="vw-overview-chip gefahr">${fehlgeschlagen} fehlgeschlagen</span>` : ""}
<span class="vw-overview-chip">${alle.length} Zahlungen gesamt</span>`;
const body = document.getElementById("zahlungen-body");
body.innerHTML = !alle.length
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
: alle
.slice(0, 60)
.map((p) => {
const status = p.payment_status || "completed";
return `<div class="vw-pay-row">
<span class="vw-pay-datum">${fmtDatum(p.paid_at)} · ${escapeHtml(p.display_name)}</span>
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : "–"}</span>
<span class="vw-pay-status status-${status}">${PAYMENT_STATUS_LABELS[status] || status}</span>
</div>`;
})
.join("");
}
function renderTabelle() {
const liste = gefilterteSortierteListe();
const tbody = document.getElementById("supporter-tbody");
document.getElementById("anzahl-text").textContent = `${liste.length} von ${alleSupporter.length} Supporter${alleSupporter.length === 1 ? "" : "n"}`;
document.querySelectorAll("#supporter-tbody").length; // no-op, hält Linter ruhig
document.querySelectorAll("th.sortierbar").forEach((th) => {
th.classList.toggle("aktiv-sort", th.dataset.sort === sortierung.feld);
th.querySelector(".pfeil").textContent = th.dataset.sort === sortierung.feld ? (sortierung.richtung === "asc" ? "↑" : "↓") : "↕";
});
if (!liste.length) {
const text = alleSupporter.length
? "Keine Treffer für diese Filter-/Suchkombination."
: "Noch keine Einträge — sobald sich jemand als Supporter registriert, taucht er hier auf.";
tbody.innerHTML = `<tr><td colspan="8"><p class="vw-leer">${text}</p></td></tr>`;
return;
}
tbody.innerHTML = liste
.map((s) => {
const status = s.sub_status || "none";
const p = s.progress || { cycleNumber: 1, currentCycleMonth: 0 };
const prozent = Math.round((p.currentCycleMonth / 30) * 100);
return `<tr class="klickbar" data-id="${s.id}">
<td><span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span></td>
<td><span class="vw-name-cell">${escapeHtml(s.display_name)}</span></td>
<td>${s.tiktok_username ? "@" + escapeHtml(s.tiktok_username) : "–"}</td>
<td class="vw-mono">${escapeHtml(s.email)}</td>
<td><span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span></td>
<td>${s.total_paid_months ?? 0}</td>
<td>
<div class="vw-mini-progress-wrap">
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
<span class="vw-mini-progress-label">Zyklus ${p.cycleNumber} · Monat ${p.currentCycleMonth}/30</span>
</div>
</td>
<td>${fmtDatum(s.created_at)}</td>
</tr>`;
})
.join("");
tbody.querySelectorAll("tr[data-id]").forEach((tr) => {
tr.addEventListener("click", () => zeigeDetail(tr.dataset.id));
});
}
document.querySelectorAll("th.sortierbar").forEach((th) => {
th.addEventListener("click", () => {
const feld = th.dataset.sort;
if (sortierung.feld === feld) sortierung.richtung = sortierung.richtung === "asc" ? "desc" : "asc";
else sortierung = { feld, richtung: feld === "created_at" ? "desc" : "asc" };
renderTabelle();
});
});
// Der Filter selbst löst renderTabelle() jetzt direkt beim Klick im
// eigenen Dropdown aus (siehe initFilterDropdown oben) statt über ein
// "change"-Event auf einem <select>, das es nicht mehr gibt.
document.getElementById("such-input").addEventListener("input", renderTabelle);
document.getElementById("refresh-btn").addEventListener("click", ladeAlleSupporter);
// CSV-Export der aktuell sichtbaren (gefilterten/durchsuchten) Ansicht —
// z.B. für Buchhaltung/Übersicht außerhalb der Seite.
document.getElementById("export-btn").addEventListener("click", () => {
const liste = gefilterteSortierteListe();
const kopf = ["Name", "TikTok", "E-Mail", "Status", "Monate gesamt", "Zyklus", "Monat im Zyklus", "Seit"];
const zeilen = liste.map((s) => [
s.display_name || "", s.tiktok_username ? "@" + s.tiktok_username : "", s.email || "",
STATUS_LABELS[s.sub_status || "none"] || "", s.total_paid_months ?? 0,
s.progress?.cycleNumber ?? 1, s.progress?.currentCycleMonth ?? 0, fmtDatum(s.created_at),
]);
const csvZeile = (arr) => arr.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(";");
const csv = "" + [csvZeile(kopf), ...zeilen.map(csvZeile)].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dogicrew-supporter-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
function kopierenKnopf(text, id) {
return `<button type="button" class="vw-copy-btn" data-copy="${escapeHtml(text)}" id="${id}" title="Kopieren" aria-label="Kopieren">📋</button>`;
}
async function zeigeDetail(supporterId) {
document.getElementById("liste-ansicht").style.display = "none";
document.getElementById("detail-ansicht").style.display = "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/detail", { supporterId });
if (!res.ok) { alert(res.error || "Konnte Details nicht laden."); return; }
const { supporter, subscription, premiums, payments } = res;
document.getElementById("detail-avatar").textContent = initialen(supporter.display_name);
document.getElementById("detail-name").textContent = supporter.display_name;
const chips = [
`📧 ${escapeHtml(supporter.email)} ${kopierenKnopf(supporter.email, "copy-email")}`,
supporter.tiktok_username
? `🎵 @${escapeHtml(supporter.tiktok_username)} ${kopierenKnopf("@" + supporter.tiktok_username, "copy-tiktok")}`
: null,
`<span class="vw-chip ${supporter.email_verified ? "verifiziert" : "unverifiziert"}" style="border:none;background:none;padding:0;">${supporter.email_verified ? "✅ E-Mail bestätigt" : "⚠️ E-Mail nicht bestätigt"}</span>`,
`🗓️ Mitglied seit ${fmtDatum(supporter.created_at)}`,
supporter.last_login_at ? `🕓 Zuletzt aktiv ${fmtDatum(supporter.last_login_at)}` : null,
].filter(Boolean);
document.getElementById("detail-chips").innerHTML = chips
.map((c) => (c.startsWith("<span") ? c : `<span class="vw-chip">${c}</span>`))
.join("");
const status = subscription?.status || "none";
document.getElementById("detail-status").innerHTML = `<span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span>`;
document.getElementById("detail-naechste").textContent = fmtDatum(subscription?.next_payment_at);
document.getElementById("detail-monate").textContent = subscription?.total_paid_months ?? 0;
const paypalId = subscription?.paypal_subscription_id;
document.getElementById("detail-paypal").innerHTML = paypalId
? `${escapeHtml(paypalId)} ${kopierenKnopf(paypalId, "copy-paypal")}`
: "–";
// Zusatzfelder je nach Situation: bei Kündigung "Zugang bis"/"Gekündigt
// am" ergänzen, statt sie zu verstecken (Nutzer-Wunsch: "nichts fehlt").
const grid = document.getElementById("detail-grid");
grid.querySelectorAll("[data-extra]").forEach((el) => el.remove());
const extras = [];
if (subscription?.cancelled_at) extras.push(["Gekündigt am", fmtDatum(subscription.cancelled_at)]);
if (subscription?.paid_period_end_at) extras.push(["Zugang bis", fmtDatum(subscription.paid_period_end_at)]);
if (subscription?.started_at) extras.push(["Abo läuft seit", fmtDatum(subscription.started_at)]);
extras.forEach(([label, value]) => {
const div = document.createElement("div");
div.dataset.extra = "1";
div.innerHTML = `<div class="vw-detail-label">${escapeHtml(label)}</div><div class="vw-detail-value">${escapeHtml(value)}</div>`;
grid.appendChild(div);
});
// Fortschritts-Kiste: wo steht dieser Supporter im 30-Monats-Zyklus
// (Nutzer-Wunsch 04.08.2026). progress/timeline kommen direkt vom
// Server (lib/supporter-cycle.js — dieselbe Rechenlogik wie im
// öffentlichen Supporter-Bereich, damit nichts auseinanderläuft).
const progressBox = document.getElementById("detail-progress-box");
if (subscription && res.progress && res.timeline) {
progressBox.style.display = "";
const { progress, timeline } = res;
const prozent = Math.round((timeline.currentCycleMonth / 30) * 100);
document.getElementById("detail-zyklus-badge").textContent = `Zyklus ${progress.cycleNumber}`;
document.getElementById("detail-progress-hero").innerHTML =
`${timeline.currentCycleMonth} <small>von 30 Monaten im aktuellen Zyklus</small>`;
document.getElementById("detail-progress-fill").style.width = `${prozent}%`;
document.getElementById("detail-progress-text").textContent =
`${progress.totalPaidMonths} Monate insgesamt bezahlt · ${prozent}% des aktuellen Zyklus geschafft`;
const celebrateEl = document.getElementById("detail-progress-celebrate");
if (progress.completedCycles > 0) {
celebrateEl.style.display = "";
celebrateEl.textContent = `🏆 Hat bereits ${progress.completedCycles} komplette Kollektion${progress.completedCycles > 1 ? "en" : ""} durchlaufen.`;
} else {
celebrateEl.style.display = "none";
}
document.getElementById("detail-progress-milestones").innerHTML = timeline.milestones
.map((m) => {
const klasse = m.reached ? "erreicht" : m.isNext ? "naechste" : "";
const statusText = m.reached ? "Freigeschaltet" : m.isNext ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
return `<div class="vw-progress-milestone ${klasse}">
<div class="med" aria-hidden="true">${m.reached ? "🎁" : m.isNext ? "✨" : "🔒"}</div>
<div class="m-monat">Monat ${m.month}</div>
<div class="m-titel">${PREMIUM_TYPE_LABELS[m.type]}</div>
<div class="m-status">${statusText}</div>
</div>`;
})
.join("");
} else {
progressBox.style.display = "none";
}
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(btn.dataset.copy);
btn.textContent = "✓"; btn.classList.add("kopiert");
setTimeout(() => { btn.textContent = "📋"; btn.classList.remove("kopiert"); }, 1400);
} catch { /* Clipboard evtl. nicht erlaubt — kein Beinbruch */ }
});
});
const premEl = document.getElementById("detail-premiums");
if (!premiums.length) {
premEl.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet.</p>`;
} else {
premEl.innerHTML = premiums
.map(
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
<div class="vw-premium-info">
<strong>${escapeHtml(p.collection_name)}</strong> — Monat ${p.milestone_month}
<div class="small" style="color:var(--text-muted);">${p.tracking_number ? "Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
</div>
<div class="vw-premium-row-fields">
<select class="status-select">
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
</select>
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
</div>
</div>`
)
.join("");
premEl.querySelectorAll("[data-save]").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest("[data-premium-id]");
const redeemStatus = row.querySelector(".status-select").value;
const trackingNumber = row.querySelector(".tracking-input").value.trim();
const erfolgEl = document.getElementById("premium-erfolg");
const fehlerEl = document.getElementById("premium-fehler");
fehlerVerstecken(fehlerEl); fehlerVerstecken(erfolgEl);
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
});
if (!r.ok) return fehlerZeigen(fehlerEl, r.error || "Speichern fehlgeschlagen.");
fehlerZeigen(erfolgEl, "Gespeichert.");
erfolgEl.classList.add("zeige");
});
});
}
const payEl = document.getElementById("detail-payments");
payEl.innerHTML = !payments.length
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
: payments
.map((p) => {
const pStatus = p.payment_status || "completed";
return `<div class="vw-pay-row">
<span class="vw-pay-datum">${fmtDatum(p.paid_at)}</span>
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : "–"}</span>
<span class="vw-pay-status status-${pStatus}">${PAYMENT_STATUS_LABELS[pStatus] || pStatus}</span>
</div>`;
})
.join("");
}
document.getElementById("zurueck-link").addEventListener("click", () => {
document.getElementById("detail-ansicht").style.display = "none";
document.getElementById("liste-ansicht").style.display = "";
});
// Nutzer-Wunsch 04.08.2026 (aktualisiert): der Zugangscode soll nur
// EINMAL PRO TAG eingegeben werden müssen, nicht bei jedem Besuch (das
// war die ursprüngliche, jetzt bewusst gelockerte Regel). admin-auth.js
// speichert die Sitzung dafür seit heute in localStorage mit eigenem
// 24-Std-Ablauf, der Server erzwingt zusätzlich dasselbe Zeitlimit
// (lib/auth.js) — läuft die Sitzung ab, verlangt pruefeSession() ganz
// automatisch wieder den Gate-Bildschirm.
pruefeSession();
})();
</script>
</body>
</html>