Der Stempel-Fund von eben hatte eine Fortsetzung: DEPLOY.md verlangte
seit dem 26.08. „ZWEI Zahlen hochzaehlen", mit Begruendung und
Messwerten daneben.
webdesign/sw.js const CACHE_NAME = "dogfather-webdesign-v64"
assets/js/wd-core.js .register("/webdesign/sw.js?v=64", …)
Gemessen am 30.09.2026 standen beide seit dem 27.08. auf v64 --
waehrend SIEBEN Commits die Dateien geaendert hatten, die der Service
Worker vorhaelt. Er haelt sechs vor, und `/assets/css/main.css` ist
eine davon.
Wer den Webdesign-Bereich einmal geoeffnet hatte, bekam sie seither
aus seinem Zwischenspeicher. Auch die Behebung von heute Vormittag
waere dort nicht angekommen.
EIN KOMMENTAR, DER VOR EINEM FEHLER WARNT, VERHINDERT IHN NICHT. Die
Anleitung war richtig, ausfuehrlich und begruendet. Getan hat es
trotzdem niemand -- fuenf Wochen lang. Das ist dieselbe Lehre wie am
11.09., als ein Warnhinweis neben einer abgeschriebenen Spaltenliste
stand und drei Spalten mit Inhalt trotzdem verlorengingen.
DESHALB MACHT ES JETZT DAS WERKZEUG. `tools/seiten-stempel.mjs`
setzt beide Zahlen auf denselben Stempel wie die Seiten. Passt eines
der zwei Muster nicht mehr, bricht es ab, statt stillschweigend
weiterzulaufen -- sonst waere die Zahl ab da wieder von Hand
gepflegt, und das merkt niemand.
Dass der Vorrat bei jedem Stempeln neu aufgebaut wird, ist Absicht:
sechs kleine Dateien kosten nichts, ein unbemerkt alter Stand fuenf
Wochen.
UND EINE WACHE DAZU. `pruef-zwischenspeicher` prueft jetzt:
· beide Zahlen stehen da
· sie sind GLEICH -- sonst wird der Vorrat geleert, aber der
Service Worker gar nicht erst neu geladen (Cloudflare ersetzt
sein `no-cache` durch vier Stunden)
· die Zahl ist nicht aelter als die vorgehaltenen Dateien
Die Liste der vorgehaltenen Dateien wird AUS DEM SERVICE WORKER
gelesen, nicht abgeschrieben -- eine zweite hier waere die, die beim
naechsten Eintrag auseinanderlaeuft.
Gegenprobe gemacht: die zwei Zahlen um eine Minute auseinander ->
rot, zurueck -> gruen.
DEPLOY.md sagt jetzt, dass es automatisch geht, und nennt den Befund
im Wortlaut daneben.
Gemessen: pruef-zwischenspeicher 34/0 (war 30/0), pruef-bewegung 9/0,
pruef-css-klassen 37/0, pruef-struktur 44/0.
Co-Authored-By: Claude Opus 5 <[email protected]>
3090 lines
169 KiB
HTML
3090 lines
169 KiB
HTML
<!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/app-symbole/verwaltung-32.png?v=202609301210" />
|
||
<!-- 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/app-symbole/verwaltung-180.png?v=202609301210" />
|
||
<meta name="apple-mobile-web-app-title" content="DogiCrew-Verwaltung" />
|
||
<meta name="theme-color" content="#8d4125" />
|
||
<link rel="stylesheet" href="assets/css/main.css?v=202609301210" />
|
||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202609301210" />
|
||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202609301210" />
|
||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202609301210" />
|
||
<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; }
|
||
|
||
/* ---------- Stimmen: kompakte Kacheln, nur erste Reihe (27.08.2026) ----------
|
||
Nutzer-Wunsch: "die kacheln sollen kleiner sein so das auch paar neben
|
||
einander stehen, und ich soll immer nur die erste reihe sehen und auf
|
||
einen button drücken um die anderen zu sehen und wieder auf dern button
|
||
damit die liste wieder zu geht."
|
||
Wie viele Kacheln in eine Reihe passen, entscheidet die Bildschirmbreite
|
||
(auto-fill). Deshalb wird die Reihenbreite NICHT fest verdrahtet, sondern
|
||
die Anzeige-Logik liest die tatsächliche Spaltenzahl aus dem Raster aus —
|
||
so stimmt "erste Reihe" am Handy (1 Kachel) genauso wie am großen
|
||
Bildschirm (3-4 Kacheln). */
|
||
.vw-stimmen-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; }
|
||
.vw-stimmen-karte { margin: 0 !important; padding: 1rem 1.05rem 1.1rem !important; }
|
||
.vw-stimmen-karte .vw-stimme-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: .55rem; flex-wrap: wrap; }
|
||
.vw-stimmen-karte .btn-row { gap: .4rem; flex-wrap: wrap; }
|
||
.vw-stimmen-karte .vw-refresh-btn { padding: .4rem .75rem; font-size: .76rem; }
|
||
.vw-stimmen-karte .vw-stimme-text { margin: .75rem 0 0; font-size: .88rem; line-height: 1.55; }
|
||
.vw-stimmen-karte .vw-stimme-avatar-block { margin-top: .85rem; padding-top: .75rem; }
|
||
.vw-stimmen-karte .vw-stimme-avatar-wahl { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); max-width: 100%; }
|
||
.vw-stimmen-karte .vw-stimme-upload { margin-top: .6rem; font-size: .76rem; }
|
||
|
||
/* Ein-/Ausklappen: gilt für die Stimmen-Kacheln UND die Supporter-Liste. */
|
||
.vw-eingeklappt { display: none !important; }
|
||
.vw-mehr-zeile { display: flex; justify-content: center; margin-top: 1.1rem; }
|
||
/* Der Knopftext ist lang ("Alle 7 Stimmen anzeigen (6 weitere)") — am Handy
|
||
muss er umbrechen dürfen, sonst schiebt er sich über den Rand hinaus. */
|
||
.vw-mehr-btn { cursor: pointer; max-width: 100%; flex-wrap: wrap; justify-content: center; white-space: normal; text-align: center; line-height: 1.35; }
|
||
.vw-mehr-btn .pf { display: inline-block; transition: transform .18s ease; }
|
||
.vw-mehr-btn[aria-expanded="true"] .pf { transform: rotate(180deg); }
|
||
@media (prefers-reduced-motion: reduce) { .vw-mehr-btn .pf { transition: none; } }
|
||
|
||
#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?v=202609301210');
|
||
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?v=202609301210');
|
||
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?v=202609301210') 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?v=202609301210'); }
|
||
.vw-gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg?v=202609301210'); }
|
||
@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?v=202609301210') 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>
|
||
<!-- Text am 27.08.2026 an die neue Regel angepasst: der Code gilt nicht
|
||
mehr einen Tag, sondern nur solange diese Seite/App offen ist. -->
|
||
<p class="vw-gate-sub">Zugangscode erforderlich — gilt nur, solange diese Seite geöffnet bleibt.</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&A-Abend" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="se-datum">Datum & 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" class="vw-stimmen-grid"></div>
|
||
<div class="vw-mehr-zeile">
|
||
<button type="button" class="vw-refresh-btn vw-mehr-btn" id="stimmen-mehr-btn" aria-controls="testimonials-liste" aria-expanded="false" hidden></button>
|
||
</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: <p>/<h3>/<strong>/<ul><li>)</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 & 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>
|
||
<!-- Nur die 4 zuletzt Angemeldeten stehen offen da, der Rest kommt
|
||
auf Knopfdruck (Nutzer-Wunsch 27.08.2026). -->
|
||
<div class="vw-mehr-zeile">
|
||
<button type="button" class="vw-refresh-btn vw-mehr-btn" id="supporter-mehr-btn" aria-controls="supporter-tbody" aria-expanded="false" hidden></button>
|
||
</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=202609301210"></script>
|
||
<script src="assets/js/data-scouts.js?v=202609301210"></script>
|
||
<script src="assets/js/data-creator.js?v=202609301210"></script>
|
||
<script src="assets/js/data-manager.js?v=202609301210"></script>
|
||
<script src="assets/js/data-stimmen-avatare.js?v=202609301210"></script>
|
||
<script src="assets/js/main.js?v=202609301210"></script>
|
||
<!-- Versionsnummer bewusst hochgesetzt (27.08.2026): Caddy liefert Assets mit
|
||
max-age=14400, ein Browser hätte die alte Anmelde-Logik sonst bis zu vier
|
||
Stunden weiterbenutzt und Dogi wäre trotz Umstellung weiter ohne Code
|
||
drin gewesen. Neue Adresse = sofort neue Datei. -->
|
||
<script src="assets/js/admin-auth.js?v=202609301210"></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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 vw-stimmen-karte" data-testimonial-id="${t.id}">
|
||
<div class="vw-stimme-kopf">
|
||
<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">
|
||
${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 class="vw-stimme-text">${escapeHtml(t.nachricht)}</p>
|
||
${avatarWahlHtml(t)}
|
||
</div>`;
|
||
}
|
||
|
||
/* ---- Nur die erste Reihe zeigen, Rest auf Knopfdruck (27.08.2026) ----
|
||
Die Spaltenzahl kommt direkt aus dem Raster (getComputedStyle liefert
|
||
bei auto-fill die tatsächlich gebauten Spuren als Pixelliste), NICHT aus
|
||
einer geratenen Zahl — sonst wäre "erste Reihe" am Handy falsch. Beim
|
||
Zuklappen werden die übrigen Kacheln mit display:none entfernt; die
|
||
Spurenliste bleibt davon unberührt, das Zählen funktioniert also auch im
|
||
zugeklappten Zustand. */
|
||
let stimmenOffen = false;
|
||
|
||
function stimmenSpalten(grid) {
|
||
const spur = getComputedStyle(grid).gridTemplateColumns;
|
||
if (!spur || spur === "none") return 1;
|
||
return Math.max(1, spur.split(" ").filter(Boolean).length);
|
||
}
|
||
|
||
function stimmenReiheAnwenden() {
|
||
const grid = document.getElementById("testimonials-liste");
|
||
const btn = document.getElementById("stimmen-mehr-btn");
|
||
if (!grid || !btn) return;
|
||
const karten = Array.from(grid.querySelectorAll("[data-testimonial-id]"));
|
||
if (!karten.length) { btn.hidden = true; return; }
|
||
const spalten = Math.min(stimmenSpalten(grid), karten.length);
|
||
karten.forEach((k, i) => k.classList.toggle("vw-eingeklappt", !stimmenOffen && i >= spalten));
|
||
const rest = karten.length - spalten;
|
||
btn.hidden = rest <= 0;
|
||
btn.setAttribute("aria-expanded", stimmenOffen ? "true" : "false");
|
||
btn.innerHTML = stimmenOffen
|
||
? `<span class="pf" aria-hidden="true">▲</span> Weniger anzeigen`
|
||
: `<span class="pf" aria-hidden="true">▼</span> Alle ${karten.length} Stimmen anzeigen (${rest} weitere)`;
|
||
}
|
||
|
||
(function initStimmenMehrKnopf() {
|
||
const btn = document.getElementById("stimmen-mehr-btn");
|
||
if (!btn) return;
|
||
btn.addEventListener("click", () => {
|
||
stimmenOffen = !stimmenOffen;
|
||
stimmenReiheAnwenden();
|
||
if (!stimmenOffen) {
|
||
const sanft = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
document.getElementById("testimonials-root").scrollIntoView({ behavior: sanft ? "smooth" : "auto", block: "start" });
|
||
}
|
||
});
|
||
// Wird das Fenster breiter/schmaler, passen andere Kachelzahlen in eine
|
||
// Reihe — im zugeklappten Zustand muss neu gerechnet werden.
|
||
let timer = null;
|
||
window.addEventListener("resize", () => {
|
||
if (stimmenOffen) return;
|
||
clearTimeout(timer);
|
||
timer = setTimeout(stimmenReiheAnwenden, 120);
|
||
});
|
||
})();
|
||
|
||
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>`;
|
||
stimmenReiheAnwenden();
|
||
return;
|
||
}
|
||
const liste = res.testimonials || [];
|
||
listeEl.innerHTML = liste.length ? liste.map(testimonialKarteHtml).join("") : `<p class="vw-leer">Noch keine Einreichungen.</p>`;
|
||
stimmenReiheAnwenden();
|
||
|
||
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("");
|
||
}
|
||
|
||
/* Standardmäßig stehen nur die 4 zuletzt Angemeldeten in der Liste, alles
|
||
Weitere kommt auf Knopfdruck (Nutzer-Wunsch 27.08.2026: "ich soll immer
|
||
nur die ersten 4 sehen also letzten angemeldeten sehen und auf einen
|
||
button drücken um die anderen zu sehen"). "Zuletzt angemeldet" ergibt
|
||
sich aus der Standard-Sortierung created_at/desc; wird anders sortiert
|
||
oder gefiltert, zeigt die Vorschau konsequenterweise die ersten 4 der
|
||
dann gültigen Reihenfolge. */
|
||
const SUPPORTER_VORSCHAU = 4;
|
||
let supporterAlleZeigen = false;
|
||
|
||
function supporterMehrKnopfSetzen(gesamt) {
|
||
const btn = document.getElementById("supporter-mehr-btn");
|
||
if (!btn) return;
|
||
const rest = gesamt - SUPPORTER_VORSCHAU;
|
||
btn.hidden = rest <= 0;
|
||
btn.setAttribute("aria-expanded", supporterAlleZeigen ? "true" : "false");
|
||
btn.innerHTML = supporterAlleZeigen
|
||
? `<span class="pf" aria-hidden="true">▲</span> Weniger anzeigen`
|
||
: `<span class="pf" aria-hidden="true">▼</span> Alle ${gesamt} anzeigen (${rest} weitere)`;
|
||
}
|
||
|
||
(function initSupporterMehrKnopf() {
|
||
const btn = document.getElementById("supporter-mehr-btn");
|
||
if (!btn) return;
|
||
btn.addEventListener("click", () => {
|
||
supporterAlleZeigen = !supporterAlleZeigen;
|
||
renderTabelle();
|
||
if (!supporterAlleZeigen) {
|
||
const sanft = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
document.getElementById("supp-root").scrollIntoView({ behavior: sanft ? "smooth" : "auto", block: "start" });
|
||
}
|
||
});
|
||
})();
|
||
|
||
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>`;
|
||
supporterMehrKnopfSetzen(0);
|
||
return;
|
||
}
|
||
supporterMehrKnopfSetzen(liste.length);
|
||
tbody.innerHTML = (supporterAlleZeigen ? liste : liste.slice(0, SUPPORTER_VORSCHAU))
|
||
.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 27.08.2026: der Zugangscode wird wieder bei JEDEM ÖFFNEN
|
||
// der Seite/App verlangt (die Lockerung "nur einmal am Tag" vom 04.08.2026
|
||
// ist damit aufgehoben). Umgesetzt in admin-auth.js über sessionStorage:
|
||
// gehört zum Tab/App-Fenster, übersteht ein Neuladen (F5, Upload), ist
|
||
// beim nächsten Öffnen aber weg. Eine Abmeldung bei Untätigkeit wurde
|
||
// ausdrücklich NICHT gewünscht. Der Server erzwingt zusätzlich sein
|
||
// eigenes 24-Std-Limit (server-internal/lib/auth.js) — fehlt oder taugt
|
||
// die Sitzung nicht, zeigt pruefeSession() automatisch wieder das Gate.
|
||
pruefeSession();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|