Nutzer-Wunsch 21.08.2026: "wenn ich eine änderung mache ist es auch für
jeden für die die schon da sind und die neuen" + "ich will ich titel
und namen von kategorien und seiten ändern können in der verwaltung".
Backend (braucht Filipes manuellen Deploy):
- Migration 0008: team_members bekommt intro/bioHtml/extraCta-Spalten
-- volle Feld-Parität mit den von Hand gepflegten Profilen (Diene/
Patrick/Bananenstift/Marina nutzen diese Felder).
- routes/team.js: neue importLegacyMember()-Funktion -- übernimmt ein
bestehendes Profil 1:1 in die Datenbank, OHNE erneut zu übersetzen
(die vorhandenen, von Hand geschriebenen Übersetzungen bleiben
erhalten). Idempotent: mehrfacher Import erzeugt keine Duplikate.
- routes/site-texts.js (neu): admin-editierbare Kategorie-Namen und
Seitentitel, generischer key->{de,...}-Override über app_settings
(wie "Event des Jahres"), fester Schlüssel-Katalog aus
Sicherheitsgründen. Leeres Feld setzt auf den Standardtext zurück.
- 27 Tests in isolierter Testumgebung geprüft (kein better-sqlite3
lokal kompilierbar).
Frontend:
- window.dogiTeamZusammenfuehren() (main.js): admin-Mitglieder
ÜBERSCHREIBEN jetzt gleichnamige statische Einträge (per slug) statt
sie zu duplizieren -- eine Bearbeitung wirkt dadurch für alle.
- window.dogiSiteTexteLaden() + applyTranslations() erweitert um
data-site-text-key -- Kategorie-Seitentitel (team-modis.html/
team-scouts.html/creator.html/manager.html) sind jetzt live editierbar.
- Verwaltung: "📥 Bestehende Mitglieder importieren"-Knopf (holt VanVan/
Diene/Funny/Miss/Marina/Ghost/Patrick/Bananenstift aus den data-*.js-
Dateien, VanVan bewusst ausgenommen -- eigene Sonderkarte + Seite),
erweitertes Formular (Intro/ausführliche Vorstellung/zweiter Button,
eingeklappt unter "Erweitert"), neue Sektion "Kategorien &
Seitentitel" (4 Karten, sofort wirksam auf Website UND Verwaltung).
Beim Testen einen echten UX-Bug gefunden und gefixt: die "Gespeichert"-
Meldung nach dem Speichern eines Mitglieds wurde von der direkt
anschließenden Formular-Zurücksetzung sofort wieder überschrieben und
war nie sichtbar.
Cache-Busting-Version auf 20260821k erhöht.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2696 lines
149 KiB
HTML
2696 lines
149 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/favicon.png" />
|
||
<!-- Eigene, separat installierbare App (20.08.2026, Nutzer-Wunsch: "die
|
||
Verwaltungsseite auch herunterladen können, getrennt von der Website
|
||
installiert") — eigenes Manifest mit eigener "id"/eigenem "scope"
|
||
(nur diese eine Seite), eigenem Namen + eigenem Icon (Violett/Gold statt
|
||
Babyblau, passend zum "Jewelen-Tresor"-Look dieser Seite). Der
|
||
Service Worker (/sw.js, siehe main.js) läuft site-weit auf Scope "/"
|
||
und deckt damit auch diese Seite automatisch mit ab — kein zweiter
|
||
Service Worker nötig, nur ein zweites Manifest. -->
|
||
<link rel="manifest" href="/manifest-verwaltung.json" />
|
||
<link rel="apple-touch-icon" href="assets/img/apple-touch-icon-admin.png" />
|
||
<meta name="apple-mobile-web-app-title" content="DogiCrew-Verwaltung" />
|
||
<meta name="theme-color" content="#0e0a16" />
|
||
<link rel="stylesheet" href="assets/css/main.css?v=20260821k" />
|
||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821k" />
|
||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821k" />
|
||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821k" />
|
||
<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.
|
||
===================================================================== */
|
||
#supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; }
|
||
body { position: relative; }
|
||
/* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift
|
||
deutlicher — nur ich seh das hier". Da fast die komplette Seite in
|
||
rem definiert ist, skaliert eine höhere Basis-Schriftgröße praktisch
|
||
alles proportional mit (Text UND viele Abstände/Paddings), ohne jede
|
||
einzelne Regel von Hand anfassen zu müssen. Nur px-Werte (Icons,
|
||
Avatare) skalieren NICHT automatisch mit — die werden unten gezielt
|
||
einzeln angehoben. */
|
||
html { font-size: 18.5px; }
|
||
|
||
/* Bugfix 20.08.2026 (Nutzer-Report per Screenshot-Vergleich: die Leiste
|
||
sieht auf der Verwaltungsseite größer aus als auf der Hauptseite,
|
||
obwohl main.css/main.js exakt identisch sind): der `rem`-Einheit oben
|
||
ist ein GLOBALER Hebel — sie erhöht auch alle Maße der gemeinsamen
|
||
Navigationsleiste um denselben Faktor (18.5/16 ≈ 16 % größer), obwohl
|
||
die Leiste selbst gar nicht Teil der "Jewelen-Tresor"-Vergrößerung sein
|
||
sollte. Dadurch brauchte dieselbe Leiste hier ~16 % mehr Breite als
|
||
überall sonst und lief bei genau der Fensterbreite über den Rand, bei
|
||
der sie auf jeder anderen Seite noch gut passte — kein Cache-Problem,
|
||
ein echter, bislang übersehener CSS-Bug. Fix: die Leiste bekommt hier
|
||
ihre Maße fest in Pixel zurück (exakt die Werte, die bei der normalen
|
||
16px-Basis herauskämen) statt in `rem`, dadurch bleibt sie unabhängig
|
||
von der Basis-Schriftgröße dieser Seite exakt gleich groß wie überall. */
|
||
header.site-header .nav-bar { gap: 32px; padding: 14.4px 0; }
|
||
header.site-header .brand { gap: 10.4px; font-size: 21.12px; }
|
||
header.site-header .brand small { font-size: 11.2px; }
|
||
header.site-header .nav-links { gap: 11.2px; }
|
||
header.site-header .nav-group > button { font-size: 18.24px; gap: 4.8px; padding: 8px 8.8px; }
|
||
header.site-header .nav-flat { font-size: 18.24px; padding: 8px 8.8px; }
|
||
header.site-header .nav-dropdown { padding: 7.2px; }
|
||
header.site-header .nav-dropdown a { padding: 8.8px 12px; font-size: 16.96px; }
|
||
header.site-header .sprach-schalter > button { font-size: 17.28px; gap: 8px; padding: 7.2px 17.6px 7.2px 9.6px; }
|
||
header.site-header .nav-cta { padding: 9.6px 21.6px; font-size: 15.2px; }
|
||
header.site-header .nav-cta-soon { gap: 3.52px; margin-left: 8px; padding: 2.4px 8px; font-size: 10.24px; }
|
||
header.site-header .nav-toggle { padding: 8px 11.2px; }
|
||
|
||
/* ---------- Durchgehende Juwelen-Aurora (nicht nur beim Login) ---------- */
|
||
.vw-aurora { position: fixed; inset: -10%; z-index: -2; pointer-events: none; filter: blur(2px); }
|
||
.vw-aurora span { position: absolute; width: 44vw; height: 44vw; border-radius: 50%; filter: blur(85px); opacity: .3; mix-blend-mode: screen; }
|
||
.vw-aurora .g1 { left: -10%; top: -8%; background: radial-gradient(circle, rgba(56,189,248,.55), transparent 70%); animation: vw-drift 26s ease-in-out infinite; }
|
||
.vw-aurora .g2 { right: -12%; top: 16%; background: radial-gradient(circle, rgba(139,92,246,.42), transparent 70%); animation: vw-drift 32s ease-in-out infinite reverse; }
|
||
.vw-aurora .g3 { left: 22%; bottom: -18%; background: radial-gradient(circle, rgba(52,211,153,.3), transparent 70%); animation: vw-drift 38s ease-in-out infinite; }
|
||
.vw-aurora .g4 { right: 8%; bottom: -12%; background: radial-gradient(circle, rgba(255,111,165,.28), transparent 70%); animation: vw-drift 34s ease-in-out infinite reverse; }
|
||
@keyframes vw-drift { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(5vw,4vh) scale(1.12); } }
|
||
@media (prefers-reduced-motion: reduce) { .vw-aurora span { animation: none; } }
|
||
|
||
/* =====================================================================
|
||
Logo-Wappen-Hintergrund (Nutzer-Wunsch 04.08.2026, 3. Runde: "ich will
|
||
auf dieser Seite auch mein Logo als Hintergrund, so richtig ultra
|
||
speziell, überrasch mich"). Zwei Ebenen:
|
||
1) Ein riesiges, sehr dezentes "Siegel" — das Logo mittig hinter der
|
||
gesamten Seite, langsam atmend, mit Gold-Glow und weichem
|
||
Rundausblenden zum Rand hin (mask-image), damit es wie ein
|
||
eingeprägtes Wappen wirkt statt wie ein plumpes Wasserzeichen.
|
||
2) Ein feines, winzig gekacheltes Muster desselben Logos, schräg
|
||
gedreht, bei nur ~2% Deckkraft — dieselbe Textur wie ein
|
||
Sicherheits-/Tresordokument, verstärkt das "Privater Bereich"-
|
||
Gefühl, ohne jemals mit dem Text zu konkurrieren.
|
||
Beide Ebenen liegen HINTER der Aurora (noch niedrigerer z-index),
|
||
damit die Farbverläufe weiter obendrüber schimmern. */
|
||
.vw-logo-bg { position: fixed; inset: 0; z-index: -4; pointer-events: none; overflow: hidden; }
|
||
.vw-logo-emblem {
|
||
position: absolute; top: 50%; left: 50%; width: min(1200px, 150vw); height: min(1200px, 150vw);
|
||
transform: translate(-50%, -50%);
|
||
background-image: url('assets/img/logo-dogfather-transparent.png');
|
||
background-size: contain; background-repeat: no-repeat; background-position: center;
|
||
opacity: .05;
|
||
filter: drop-shadow(0 0 120px rgba(56,189,248,.55)) drop-shadow(0 0 60px rgba(139,92,246,.25));
|
||
mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
|
||
-webkit-mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
|
||
animation: vw-emblem-atem 9s ease-in-out infinite;
|
||
}
|
||
@keyframes vw-emblem-atem {
|
||
0%, 100% { opacity: .045; transform: translate(-50%, -50%) scale(1); }
|
||
50% { opacity: .085; transform: translate(-50%, -50%) scale(1.035); }
|
||
}
|
||
.vw-logo-tile {
|
||
position: absolute; inset: -25%;
|
||
background-image: url('assets/img/logo-dogfather-transparent.png');
|
||
background-size: 150px 150px; background-repeat: repeat;
|
||
opacity: .02; transform: rotate(-14deg);
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { .vw-logo-emblem { animation: none; } }
|
||
|
||
.vw-card { position: relative; overflow: hidden; border: 1.5px solid rgba(56,189,248,.35);
|
||
background: radial-gradient(circle at 18% 0%, rgba(56,189,248,.1), transparent 55%), linear-gradient(180deg, rgba(23,27,33,.92), rgba(14,16,20,.96)); }
|
||
|
||
/* Feines Foliekorn (dieselbe SVG-Turbulence wie .btn-holo im Hauptstyle)
|
||
für einen materiellen, "echten" Tresor-Oberflächen-Look statt platter
|
||
Flächen — sehr dezent (opacity), nur auf den großen Schimmer-Karten. */
|
||
.vw-noise::after {
|
||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: .05; mix-blend-mode: overlay;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||
background-size: 160px 160px;
|
||
}
|
||
.vw-noise > * { position: relative; z-index: 1; }
|
||
|
||
/* Juwelen-Schimmer-Rand: läuft durch Gold → Amethyst → Rosé → Smaragd →
|
||
Gold, statt nur eintönigem Gold — der "andere Farben"-Wunsch. Immer
|
||
derselbe zweischichtige background-position-Trick, NIE rotieren. */
|
||
.vw-shine-card {
|
||
position: relative; isolation: isolate; overflow: hidden;
|
||
border-radius: 18px; border: 1.5px solid transparent;
|
||
background:
|
||
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.97)) padding-box,
|
||
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
|
||
background-size: 100% 100%, 340% 100%;
|
||
animation: vw-stat-shimmer 10s linear infinite;
|
||
box-shadow: 0 14px 34px -18px rgba(0,0,0,.75);
|
||
}
|
||
@keyframes vw-stat-shimmer { to { background-position: 0 0, -340% 0; } }
|
||
@media (prefers-reduced-motion: reduce) { .vw-shine-card { animation: none; } }
|
||
|
||
.vw-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .28rem .7rem; border-radius: 999px; font-size: .74rem; font-weight: 700; }
|
||
.vw-badge.active { background: rgba(52,211,153,.15); color: #5eeaa7; box-shadow: 0 0 12px rgba(52,211,153,.2); }
|
||
.vw-badge.pending { background: rgba(56,189,248,.15); color: #7dd3fc; }
|
||
.vw-badge.suspended, .vw-badge.expired { background: rgba(217,74,92,.15); color: #ff9a9a; }
|
||
.vw-badge.cancelled { background: rgba(255,255,255,.08); color: var(--text-muted); }
|
||
.vw-badge.none { background: rgba(255,255,255,.06); color: var(--text-muted); }
|
||
|
||
.vw-back { display: inline-flex; align-items: center; gap: .4rem; cursor: pointer; color: #7dd3fc; font-size: .9rem; font-weight: 600; transition: gap .15s ease; }
|
||
.vw-back:hover { gap: .65rem; }
|
||
.vw-fehler { color: #ff9a9a; font-size: .88rem; margin-top: .6rem; display: none; }
|
||
.vw-fehler.zeige { display: block; }
|
||
.vw-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .6rem; display: none; }
|
||
.vw-erfolg.zeige { display: block; }
|
||
|
||
/* =====================================================================
|
||
Login-Gate: Tresor-/Sicherheits-Optik statt einer schlichten Karte.
|
||
===================================================================== */
|
||
#gate-container { position: relative; min-height: 70vh; display: flex; align-items: center; }
|
||
.vw-gate-wrap { max-width: 780px; }
|
||
.vw-gate-head { margin-bottom: 1.6rem; }
|
||
|
||
.vw-gate-grid {
|
||
display: flex; flex-wrap: wrap; gap: 1.3rem; justify-content: center; align-items: stretch;
|
||
}
|
||
.vw-gate-card {
|
||
position: relative; isolation: isolate; overflow: hidden;
|
||
flex: 1 1 280px; max-width: 340px;
|
||
padding: 1.7rem 1.5rem 1.6rem;
|
||
border-radius: 20px; border: 1.5px solid transparent;
|
||
background:
|
||
linear-gradient(rgba(16,18,22,.95), rgba(9,10,13,.97)) padding-box,
|
||
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
|
||
background-size: 100% 100%, 340% 100%;
|
||
animation: vw-stat-shimmer 10s linear infinite;
|
||
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(56,189,248,.35);
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { .vw-gate-card { animation: none; } }
|
||
|
||
/* VanVans Kiste: bewusst eigene, femininere Farbwelt (Rosé/Lavendel/Gold
|
||
statt Babyblau/Lila) und ein Blüten- statt Schloss-Symbol — keine
|
||
bloße Umfärbung von Dogis Kiste, damit beide klar als "ihre eigene"
|
||
erkennbar sind. */
|
||
.vw-gate-card--vanvan {
|
||
background:
|
||
linear-gradient(rgba(24,15,20,.95), rgba(15,9,13,.97)) padding-box,
|
||
linear-gradient(110deg, #ff8fc6, #ffd1e8, #c084fc, #ffd76a, #ff8fc6) border-box;
|
||
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(255,143,198,.4);
|
||
}
|
||
|
||
/* Nutzer-Wunsch 04.08.2026 (7. Runde): "richtig geil, mega speziell" —
|
||
die drei Platzhalter-Symbole (🔒 im großen Kreis oben, 🐺 bei Dogi,
|
||
🌸 bei VanVan) durch echte Fotos ersetzt: derselbe HasiDog-Schnitt wie
|
||
beim "Privater Bereich"-Wappen oben auf der Seite (gate-siegel-
|
||
hasidog.jpg), ein echtes Portrait für Dogis Kiste (vw-gate-dogi.jpg)
|
||
und ein echtes Portrait für VanVans Kiste (vw-gate-vanvan.jpg). Alle
|
||
drei als rundes Foto mit pulsierendem Farb-Ring statt Emoji, damit es
|
||
zum Rest der Schmuck-/Siegel-Optik dieser Seite passt. */
|
||
.vw-gate-schloss {
|
||
width: 72px; height: 72px; margin: 0 auto 1rem; border-radius: 50%;
|
||
background: radial-gradient(circle at 32% 28%, rgba(125,211,252,.35), rgba(56,189,248,.08) 70%),
|
||
url('assets/img/gate-siegel-hasidog.jpg') center / cover no-repeat;
|
||
border: 2px solid rgba(56,189,248,.6);
|
||
box-shadow: 0 0 26px rgba(56,189,248,.4), inset 0 0 12px rgba(255,255,255,.08);
|
||
animation: vw-schloss-puls 2.6s ease-in-out infinite;
|
||
}
|
||
@keyframes vw-schloss-puls {
|
||
0%, 100% { box-shadow: 0 0 20px rgba(56,189,248,.3), inset 0 0 12px rgba(255,255,255,.06); }
|
||
50% { box-shadow: 0 0 38px rgba(56,189,248,.6), inset 0 0 16px rgba(255,255,255,.12); }
|
||
}
|
||
.vw-gate-eyebrow {
|
||
display: block; text-align: center; font-size: .78rem; font-weight: 800; letter-spacing: .18em;
|
||
text-transform: uppercase; color: #ff9a9a; margin-bottom: .5rem;
|
||
}
|
||
.vw-gate-titel {
|
||
text-align: center; font-size: 1.6rem; font-weight: 800; margin: 0 0 .4rem;
|
||
background: linear-gradient(135deg, #fff, #7dd3fc 55%, #38bdf8);
|
||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||
}
|
||
.vw-gate-sub { text-align: center; font-size: .92rem; color: var(--text-muted); margin-bottom: 0; }
|
||
|
||
/* Kleine Kiste pro Person: eigenes Portrait-Foto + Name-Überschrift
|
||
statt Tier-Emoji, mit demselben Puls-Ring-Mechanismus wie oben, nur
|
||
in der jeweiligen Kartenfarbe. */
|
||
.vw-gate-card-icon {
|
||
display: block; width: 64px; height: 64px; margin: 0 auto .6rem; border-radius: 50%;
|
||
background-size: cover; background-position: center;
|
||
border: 2px solid rgba(56,189,248,.6);
|
||
box-shadow: 0 0 20px rgba(56,189,248,.4), inset 0 0 10px rgba(255,255,255,.08);
|
||
animation: vw-schloss-puls 2.6s ease-in-out infinite;
|
||
}
|
||
.vw-gate-card-icon--dogi { background-image: url('assets/img/vw-gate-dogi.jpg'); }
|
||
.vw-gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg'); }
|
||
@keyframes vw-schloss-puls-rose {
|
||
0%, 100% { box-shadow: 0 0 20px rgba(255,143,198,.35), inset 0 0 10px rgba(255,255,255,.06); }
|
||
50% { box-shadow: 0 0 36px rgba(255,143,198,.65), inset 0 0 14px rgba(255,255,255,.12); }
|
||
}
|
||
.vw-gate-card--vanvan .vw-gate-card-icon {
|
||
border-color: rgba(255,143,198,.65);
|
||
animation-name: vw-schloss-puls-rose;
|
||
}
|
||
.vw-gate-card-name {
|
||
text-align: center; font-size: 1.15rem; font-weight: 800; margin: 0 0 1rem; color: var(--text);
|
||
}
|
||
.vw-gate-card--vanvan .vw-gate-card-name { color: #ffd1e8; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.vw-gate-schloss, .vw-gate-card-icon { animation: none; }
|
||
}
|
||
|
||
.vw-gate-card label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin-bottom: .4rem; }
|
||
.vw-gate-card input {
|
||
width: 100%; box-sizing: border-box; padding: .85rem 1rem; border-radius: 14px;
|
||
background: rgba(255,255,255,.04); border: 1.5px solid var(--border); color: var(--text);
|
||
font-size: 1.05rem; letter-spacing: .25em; text-align: center;
|
||
transition: border-color .15s ease, box-shadow .15s ease;
|
||
}
|
||
.vw-gate-card input:focus { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 4px rgba(56,189,248,.14); }
|
||
.vw-gate-card--vanvan input:focus { border-color: rgba(255,143,198,.7); box-shadow: 0 0 0 4px rgba(255,143,198,.16); }
|
||
|
||
/* Bugfix 04.08.2026: .btn-holo (main.css) ist für große, freistehende
|
||
Haupt-Knöpfe gedacht (großzügiges Padding, Großbuchstaben) — in einer
|
||
schmalen halben Login-Kiste (erst recht auf Mobilgeräten) brach der
|
||
Text dadurch auf zwei Zeilen um und wirkte riesig. Eigener, deutlich
|
||
kompakterer Knopf-Stil nur für diese Kisten. */
|
||
.vw-gate-card .btn-holo.vw-gate-btn {
|
||
width: 100%; justify-content: center; margin-top: 1.3rem;
|
||
padding: .6rem 1rem; font-size: .88rem; letter-spacing: .01em;
|
||
gap: .3rem;
|
||
}
|
||
.vw-gate-btn--vanvan {
|
||
background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%);
|
||
background-size: 340% 340%;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.vw-gate-grid { flex-direction: column; align-items: center; }
|
||
.vw-gate-card { max-width: 340px; width: 100%; }
|
||
}
|
||
|
||
/* =====================================================================
|
||
Hero: persönliches "Command Center" statt generischer Seitenkopf.
|
||
===================================================================== */
|
||
.vw-hero-crest {
|
||
width: 116px; height: 116px; margin: 0 auto 1.2rem; border-radius: 50%;
|
||
background:
|
||
radial-gradient(circle at 32% 26%, rgba(125,211,252,.25), rgba(56,189,248,.08) 72%),
|
||
url('assets/img/hasidog-kopf-siegel.jpg') center / cover no-repeat;
|
||
border: 2.5px solid rgba(56,189,248,.6);
|
||
box-shadow: 0 0 34px rgba(56,189,248,.4), inset 0 0 18px rgba(0,0,0,.35), inset 0 0 0 3px rgba(10,12,16,.5);
|
||
animation: vw-schloss-puls 3.4s ease-in-out infinite;
|
||
}
|
||
.vw-hero-eyebrow {
|
||
display: flex; align-items: center; justify-content: center; gap: .4rem;
|
||
font-size: .78rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase;
|
||
color: #ff9a9a; margin-bottom: .6rem;
|
||
}
|
||
.vw-hero-titel {
|
||
font-size: clamp(1.9rem, 4vw, 2.7rem); font-weight: 900; text-align: center; margin: 0 0 .5rem;
|
||
background: linear-gradient(100deg, #fff, #7dd3fc 30%, #8b5cf6 50%, #ff6fa5 65%, #38bdf8 85%, #fff);
|
||
background-size: 260% 100%; -webkit-background-clip: text; background-clip: text; color: transparent;
|
||
animation: vw-titel-glanz 9s ease-in-out infinite;
|
||
}
|
||
@keyframes vw-titel-glanz { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||
.vw-hero-gruss { text-align: center; font-size: 1.02rem; color: #7dd3fc; font-weight: 700; margin-bottom: .3rem; }
|
||
.vw-hero-sub { text-align: center; color: var(--text-muted); max-width: 560px; margin: 0 auto; }
|
||
@media (prefers-reduced-motion: reduce) { .vw-hero-crest, .vw-hero-titel { animation: none; } }
|
||
|
||
/* ---------- Session-Leiste ---------- */
|
||
.vw-session-bar {
|
||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;
|
||
padding: .9rem 1.3rem; border-radius: 14px;
|
||
}
|
||
.vw-owner-badge {
|
||
display: inline-flex; align-items: center; gap: .5rem; padding: .4rem .9rem .4rem .6rem; border-radius: 999px;
|
||
background: radial-gradient(circle at 20% 30%, rgba(125,211,252,.18), rgba(56,189,248,.06));
|
||
border: 1.5px solid rgba(56,189,248,.4); font-weight: 800; font-size: .84rem; color: #7dd3fc;
|
||
box-shadow: 0 0 16px rgba(56,189,248,.2);
|
||
}
|
||
.vw-owner-badge .krone { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
|
||
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.6), rgba(56,189,248,.15) 70%); }
|
||
.vw-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #5eeaa7; box-shadow: 0 0 8px #5eeaa7; animation: vw-live-puls 2s ease-in-out infinite; }
|
||
@keyframes vw-live-puls { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
||
.vw-uhrzeit { font-size: .82rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: .4rem; }
|
||
.vw-session-right { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
|
||
.vw-logout-btn { transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; }
|
||
.vw-logout-btn:hover { border-color: rgba(56,189,248,.6); box-shadow: 0 0 16px rgba(56,189,248,.2); transform: translateY(-1px); }
|
||
|
||
/* ---------- Kennzahlen-Übersicht: jede Kachel ihr eigener Edelstein ---------- */
|
||
.vw-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1.1rem; margin-bottom: 2rem; }
|
||
.vw-stat {
|
||
position: relative; isolation: isolate; overflow: hidden;
|
||
display: flex; flex-direction: column; align-items: flex-start; gap: .7rem;
|
||
border-radius: 18px; padding: 1.6rem 1.5rem 1.7rem;
|
||
border: 1.5px solid transparent;
|
||
background:
|
||
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.96)) padding-box,
|
||
linear-gradient(120deg, color-mix(in srgb, var(--sc) 55%, transparent), color-mix(in srgb, var(--sc) 90%, white 15%), color-mix(in srgb, var(--sc) 55%, transparent)) border-box;
|
||
background-size: 100% 100%, 280% 100%;
|
||
animation: vw-stat-shimmer-solo 6s ease-in-out infinite;
|
||
box-shadow: 0 14px 30px -18px rgba(0,0,0,.7);
|
||
transition: transform .2s ease, box-shadow .2s ease;
|
||
}
|
||
@keyframes vw-stat-shimmer-solo { 0%,100% { background-position: 0 0, 0% 50%; } 50% { background-position: 0 0, 100% 50%; } }
|
||
.vw-stat:hover { transform: translateY(-4px); box-shadow: 0 20px 40px -16px rgba(0,0,0,.75), 0 0 26px color-mix(in srgb, var(--sc) 40%, transparent); }
|
||
.vw-stat--violet { --sc: #8b5cf6; }
|
||
.vw-stat--emerald { --sc: #34d399; }
|
||
.vw-stat--blau { --sc: #38bdf8; }
|
||
.vw-stat--rose { --sc: #ff6fa5; }
|
||
.vw-stat-icon {
|
||
flex: none; width: 56px; height: 56px; border-radius: 50%; font-size: 1.55rem;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: radial-gradient(circle at 30% 25%, color-mix(in srgb, var(--sc) 55%, white 20%), color-mix(in srgb, var(--sc) 15%, transparent) 72%);
|
||
border: 1.5px solid color-mix(in srgb, var(--sc) 55%, transparent);
|
||
box-shadow: 0 0 16px color-mix(in srgb, var(--sc) 35%, transparent), inset 0 0 10px rgba(255,255,255,.06);
|
||
}
|
||
.vw-stat .label { font-size: .8rem; font-weight: 800; color: var(--sc); margin-top: .2rem; text-transform: uppercase; letter-spacing: .06em; opacity: .95; }
|
||
.vw-stat .value { font-size: 1.65rem; font-weight: 800; line-height: 1.3; }
|
||
.vw-stat .value small { font-size: .76rem; font-weight: 600; color: var(--text-muted); text-transform: none; letter-spacing: 0; }
|
||
@media (prefers-reduced-motion: reduce) { .vw-stat { animation: none; } }
|
||
/* Fallback für Browser ohne color-mix() — bewusst konservativ, sieht
|
||
trotzdem gut aus statt kaputt (main.css unterstützt bereits moderne
|
||
CSS-Features, aber sicher ist sicher). */
|
||
@supports not (color: color-mix(in srgb, red, blue)) {
|
||
.vw-stat--violet .vw-stat-icon, .vw-stat--violet { border-color: rgba(139,92,246,.5); }
|
||
.vw-stat--emerald .vw-stat-icon, .vw-stat--emerald { border-color: rgba(52,211,153,.5); }
|
||
.vw-stat--rose .vw-stat-icon, .vw-stat--rose { border-color: rgba(255,111,165,.5); }
|
||
}
|
||
|
||
/* ---------- Toolbar / Filter / Suche ---------- */
|
||
.vw-toolbar { display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; padding: 1rem 1.2rem; margin-bottom: 1.4rem; }
|
||
.vw-toolbar label { font-size: .8rem; color: var(--text-muted); font-weight: 600; }
|
||
.vw-select-wrap { position: relative; }
|
||
.vw-select-btn {
|
||
display: flex; align-items: center; justify-content: space-between; gap: .8rem; width: 100%;
|
||
min-width: 190px; appearance: none; -webkit-appearance: none; padding: .6rem 2.2rem .6rem .9rem; border-radius: 10px;
|
||
background: rgba(255,255,255,.04); border: 1.5px solid rgba(56,189,248,.3); color: var(--text); font-weight: 600;
|
||
font-family: inherit; font-size: .95rem; text-align: left; cursor: pointer;
|
||
transition: border-color .15s ease, box-shadow .15s ease;
|
||
}
|
||
.vw-select-btn:hover, .vw-select-wrap.open .vw-select-btn { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 3px rgba(56,189,248,.12); }
|
||
.vw-select-wrap::after {
|
||
content: "▾"; position: absolute; right: .9rem; top: 50%; transform: translateY(-50%); pointer-events: none;
|
||
color: #7dd3fc; font-size: .8rem; transition: transform .15s ease;
|
||
}
|
||
.vw-select-wrap.open::after { transform: translateY(-50%) rotate(180deg); }
|
||
|
||
/* Eigenes Dropdown statt der nativen <select>-Liste — die wird vom
|
||
Browser/Betriebssystem gezeichnet und lässt sich mit CSS praktisch
|
||
nicht themen (siehe Kommentar oben bei der HTML-Struktur). */
|
||
/* Bugfix 04.08.2026: "position:fixed" führte auf dieser Seite (viele
|
||
verschachtelte "isolation:isolate"/"overflow:hidden"-Schimmer-Karten
|
||
im Seitenbaum) zu einem echten Compositing-Fehler des Browsers — die
|
||
Liste wurde beim Rendern teilweise dupliziert/an falscher Stelle
|
||
gemalt (reproduzierbar über mehrere unabhängige Testmethoden
|
||
verifiziert, es lag NICHT an Scroll-Handling oder der Animation).
|
||
Fix: "Portal"-Muster — die Liste wird per JS beim Öffnen direkt als
|
||
Kind von <body> angehängt (siehe initFilterDropdown) und dokument-
|
||
relativ absolut positioniert, komplett außerhalb jeder Karten-
|
||
Verschachtelung. Dasselbe Muster wird von den meisten UI-Bibliotheken
|
||
genau für solche Fälle verwendet. */
|
||
.vw-select-list {
|
||
position: absolute; z-index: 200; list-style: none;
|
||
margin: 0; width: max-content; max-width: 260px; padding: .4rem;
|
||
border-radius: 14px; border: 1.5px solid rgba(56,189,248,.4);
|
||
background: rgba(10,12,16,.98);
|
||
box-shadow: 0 14px 34px rgba(0,0,0,.6), 0 0 26px rgba(56,189,248,.22);
|
||
}
|
||
/* Bugfix-Muster aus früheren Runden (Spezifitätskonflikt [hidden] vs.
|
||
Autor-CSS) hier vorsorglich vermieden: .vw-select-list selbst setzt
|
||
kein "display", nur diese Regel — [hidden] gewinnt dadurch immer. */
|
||
.vw-select-list[hidden] { display: none; }
|
||
/* Bugfix 04.08.2026: eine CSS-"animation" mit fill-mode "both" blieb
|
||
nach dem Portal-Umzug (siehe initFilterDropdown) manchmal exakt im
|
||
Startzustand hängen (opacity:0) statt am Ende bei opacity:1 zu
|
||
landen — vermutlich, weil das Neu-Anhängen an <body> plus die
|
||
Scroll/Resize-Neupositionierung die Animation mehrfach neu
|
||
angestoßen hat. Einfacher & zuverlässiger ohne Animation. */
|
||
.vw-select-list li {
|
||
padding: .55rem .7rem; border-radius: 9px; font-size: .88rem; font-weight: 600; color: var(--text);
|
||
cursor: pointer; border-left: 3px solid transparent; transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||
}
|
||
.vw-select-list li:hover { background: rgba(56,189,248,.14); }
|
||
.vw-select-list li.selected { color: #7dd3fc; border-left-color: #38bdf8; background: rgba(56,189,248,.1); }
|
||
|
||
.vw-search-wrap { position: relative; flex: 1 1 220px; max-width: 320px; }
|
||
.vw-search-wrap input {
|
||
width: 100%; box-sizing: border-box; padding: .6rem .9rem .6rem 2.2rem; border-radius: 10px;
|
||
background: rgba(255,255,255,.04); border: 1.5px solid rgba(139,92,246,.35); color: var(--text);
|
||
transition: border-color .15s ease, box-shadow .15s ease;
|
||
}
|
||
.vw-search-wrap input:focus { outline: none; border-color: rgba(139,92,246,.75); box-shadow: 0 0 0 3px rgba(139,92,246,.15); }
|
||
.vw-search-wrap::before { content: "🔍"; position: absolute; left: .75rem; top: 50%; transform: translateY(-50%); font-size: .85rem; opacity: .7; pointer-events: none; }
|
||
|
||
.vw-refresh-btn, .vw-export-btn {
|
||
display: inline-flex; align-items: center; gap: .5rem; padding: .58rem 1.1rem; border-radius: 999px;
|
||
background: rgba(56,189,248,.08); border: 1.5px solid rgba(56,189,248,.35); color: #7dd3fc; font-weight: 700; font-size: .85rem;
|
||
cursor: pointer; transition: background .15s ease, border-color .15s ease, transform .15s ease;
|
||
}
|
||
.vw-refresh-btn:hover, .vw-export-btn:hover { background: rgba(56,189,248,.16); border-color: rgba(56,189,248,.6); }
|
||
.vw-export-btn { background: rgba(52,211,153,.08); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
|
||
.vw-export-btn:hover { background: rgba(52,211,153,.16); border-color: rgba(52,211,153,.6); }
|
||
.vw-refresh-btn .ic { display: inline-block; transition: transform .5s ease; }
|
||
.vw-refresh-btn.lädt .ic { animation: vw-spin .6s linear infinite; }
|
||
@keyframes vw-spin { to { transform: rotate(360deg); } }
|
||
.vw-anzahl { margin-left: auto; font-size: .85rem; color: var(--text-muted); font-weight: 600; }
|
||
|
||
/* ---------- Supporter-Tabelle ---------- */
|
||
.vw-table-wrap { overflow-x: auto; border-radius: 18px; }
|
||
.vw-table { width: 100%; border-collapse: collapse; font-size: .92rem; min-width: 680px; }
|
||
.vw-table thead th {
|
||
position: sticky; top: 0; z-index: 1;
|
||
text-align: left; padding: .8rem .9rem; color: #7dd3fc; font-size: .72rem; text-transform: uppercase;
|
||
letter-spacing: .06em; font-weight: 800; background: rgba(10,15,22,.94); border-bottom: 1.5px solid rgba(56,189,248,.35);
|
||
}
|
||
.vw-table thead th.sortierbar { cursor: pointer; user-select: none; transition: color .15s ease; }
|
||
.vw-table thead th.sortierbar:hover { color: #fff; }
|
||
.vw-table thead th .pfeil { opacity: .5; margin-left: .25rem; font-size: .7rem; }
|
||
.vw-table thead th.aktiv-sort .pfeil { opacity: 1; color: #ff6fa5; }
|
||
.vw-table tbody td { padding: .75rem .9rem; border-bottom: 1px solid rgba(255,255,255,.05); vertical-align: middle; }
|
||
.vw-table tbody tr { transition: background .15s ease, transform .15s ease; }
|
||
.vw-table tbody tr:nth-child(even) { background: rgba(255,255,255,.015); }
|
||
.vw-table tr.klickbar { cursor: pointer; }
|
||
.vw-table tr.klickbar:hover { background: rgba(139,92,246,.09); }
|
||
.vw-avatar {
|
||
width: 40px; height: 40px; border-radius: 50%; flex: none;
|
||
display: flex; align-items: center; justify-content: center; font-size: .92rem; font-weight: 800;
|
||
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.4), rgba(56,189,248,.12) 75%);
|
||
border: 1.5px solid rgba(56,189,248,.4); color: #7dd3fc;
|
||
}
|
||
.vw-name-cell { display: flex; align-items: center; gap: .7rem; font-weight: 700; }
|
||
.vw-mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .85rem; color: var(--text-muted); }
|
||
|
||
/* ---------- Detailansicht ---------- */
|
||
.vw-detail-head { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
|
||
.vw-avatar-lg {
|
||
width: 78px; height: 78px; border-radius: 50%; flex: none;
|
||
display: flex; align-items: center; justify-content: center; font-size: 1.8rem; font-weight: 800;
|
||
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.5), rgba(56,189,248,.15) 75%);
|
||
border: 1.5px solid rgba(56,189,248,.5); color: #7dd3fc;
|
||
box-shadow: 0 0 20px rgba(56,189,248,.25);
|
||
}
|
||
.vw-detail-name { margin: 0 0 .35rem; font-size: 1.4rem; }
|
||
.vw-chip-row { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||
.vw-chip {
|
||
display: inline-flex; align-items: center; gap: .35rem; padding: .3rem .7rem; border-radius: 999px;
|
||
font-size: .8rem; font-weight: 600; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text-muted);
|
||
}
|
||
.vw-chip.verifiziert { background: rgba(52,211,153,.1); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
|
||
.vw-chip.unverifiziert { background: rgba(217,74,92,.1); border-color: rgba(217,74,92,.35); color: #ff9a9a; }
|
||
.vw-copy-btn {
|
||
display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 50%;
|
||
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer;
|
||
font-size: .88rem; transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||
}
|
||
.vw-copy-btn:hover { background: rgba(56,189,248,.15); border-color: rgba(56,189,248,.5); color: #7dd3fc; }
|
||
.vw-copy-btn.kopiert { background: rgba(52,211,153,.15); border-color: rgba(52,211,153,.5); color: #5eeaa7; }
|
||
|
||
.vw-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .9rem 1.1rem; margin: 1.4rem 0 .4rem; }
|
||
.vw-detail-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .8; font-weight: 700; }
|
||
.vw-detail-value { font-size: 1.02rem; font-weight: 700; margin-top: .15rem; }
|
||
|
||
.vw-subh { font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .85; margin: 1.8rem 0 .8rem; display: flex; align-items: center; gap: .4rem; }
|
||
|
||
/* ---------- Mini-Fortschrittsbalken in der Tabelle ---------- */
|
||
.vw-mini-progress-wrap { display: flex; flex-direction: column; gap: .3rem; min-width: 130px; }
|
||
.vw-mini-progress { height: 9px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; border: 1px solid var(--border); }
|
||
.vw-mini-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8); box-shadow: 0 0 8px rgba(56,189,248,.4); transition: width .4s ease; }
|
||
.vw-mini-progress-label { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
|
||
|
||
/* ---------- Fortschritts-Kiste in der Detailansicht ---------- */
|
||
.vw-progress-box { padding: 1.6rem 1.6rem 1.8rem; margin-top: 1.6rem; }
|
||
.vw-progress-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .8rem; margin-bottom: 1rem; }
|
||
.vw-progress-zyklus {
|
||
display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .85rem; border-radius: 999px;
|
||
background: rgba(139,92,246,.12); border: 1.5px solid rgba(139,92,246,.4); color: #c4b0fb; font-weight: 800; font-size: .82rem;
|
||
}
|
||
.vw-progress-hero { font-size: 2.1rem; font-weight: 900; line-height: 1; margin-bottom: .3rem; }
|
||
.vw-progress-hero small { display: block; font-size: .8rem; font-weight: 600; color: var(--text-muted); margin-top: .3rem; }
|
||
.vw-progress-bar-big { height: 18px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); margin: 1rem 0 .5rem; }
|
||
.vw-progress-bar-big-fill {
|
||
height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8, #7dd3fc);
|
||
background-size: 220% 100%; animation: abo-progress-shine 5s ease-in-out infinite; box-shadow: 0 0 14px rgba(56,189,248,.45);
|
||
transition: width .5s ease;
|
||
}
|
||
@keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||
/* Supporter-Abstimmungen (20.08.2026) */
|
||
.vw-poll-option { margin-bottom: .8rem; }
|
||
.vw-poll-option:last-child { margin-bottom: 0; }
|
||
.vw-poll-option-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
|
||
.vw-poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
|
||
.vw-poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5); box-shadow: 0 0 10px rgba(139,92,246,.4); transition: width .4s ease; }
|
||
.vw-progress-celebrate {
|
||
margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700;
|
||
background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc;
|
||
}
|
||
.vw-progress-milestones { display: flex; flex-wrap: wrap; gap: .8rem; margin-top: 1.4rem; }
|
||
.vw-progress-milestone {
|
||
flex: 1 1 140px; text-align: center; padding: 1rem .6rem .8rem; border-radius: 14px;
|
||
border: 1.5px solid var(--border); background: rgba(255,255,255,.02); transition: transform .18s ease, border-color .18s ease;
|
||
}
|
||
.vw-progress-milestone.erreicht { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
|
||
.vw-progress-milestone.naechste { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
|
||
.vw-progress-milestone:hover { transform: translateY(-3px); }
|
||
.vw-progress-milestone .med {
|
||
width: 56px; height: 56px; margin: 0 auto .4rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
|
||
background: radial-gradient(circle at 32% 26%, rgba(255,255,255,.12), transparent 65%); border: 1.5px solid var(--border);
|
||
}
|
||
.vw-progress-milestone.erreicht .med { border-color: #7dd3fc; background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.55), rgba(56,189,248,.12) 70%); }
|
||
.vw-progress-milestone.naechste .med { border-color: #38bdf8; background: radial-gradient(circle at 32% 26%, rgba(56,189,248,.4), transparent 70%); }
|
||
.vw-progress-milestone .m-monat { font-size: .74rem; color: var(--text-muted); }
|
||
.vw-progress-milestone .m-titel { font-weight: 800; font-size: .85rem; margin-top: .15rem; }
|
||
.vw-progress-milestone .m-status { font-size: .72rem; margin-top: .3rem; color: var(--text-muted); }
|
||
|
||
.vw-premium-card {
|
||
display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; padding: .9rem 1rem; border-radius: 14px;
|
||
background: rgba(255,255,255,.02); border: 1px solid var(--border); margin-bottom: .7rem;
|
||
}
|
||
.vw-premium-icon {
|
||
flex: none; width: 50px; height: 50px; border-radius: 50%; font-size: 1.35rem;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.4), rgba(56,189,248,.1) 70%);
|
||
border: 1.5px solid rgba(56,189,248,.4);
|
||
}
|
||
.vw-premium-info { flex: 1 1 180px; min-width: 0; }
|
||
.vw-premium-info strong { display: block; }
|
||
.vw-premium-row-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; }
|
||
.vw-premium-status {
|
||
display: inline-block; font-size: .74rem; font-weight: 700; padding: .22rem .6rem; border-radius: 999px;
|
||
color: var(--text-muted); background: rgba(255,255,255,.06); white-space: nowrap;
|
||
}
|
||
.vw-premium-status.status-verfuegbar { background: rgba(56,189,248,.15); color: #7dd3fc; }
|
||
.vw-premium-status.status-wird_geprueft, .vw-premium-status.status-wird_vorbereitet { background: rgba(255,111,165,.15); color: #ff9fc4; }
|
||
.vw-premium-status.status-versandbereit, .vw-premium-status.status-versendet { background: rgba(56,189,248,.15); color: #38bdf8; }
|
||
.vw-premium-status.status-zugestellt, .vw-premium-status.status-erhalten { background: rgba(52,211,153,.15); color: #5eeaa7; }
|
||
.vw-premium-status.status-problem, .vw-premium-status.status-ersatz_wird_vorbereitet { background: rgba(217,74,92,.15); color: #ff9a9a; }
|
||
.vw-premium-card select {
|
||
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text); font-size: .85rem;
|
||
}
|
||
.vw-premium-card input[type="text"] {
|
||
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text);
|
||
font-size: .85rem; max-width: 160px;
|
||
}
|
||
|
||
.vw-pay-liste { display: flex; flex-direction: column; gap: .45rem; max-height: 280px; overflow-y: auto; padding-right: .3rem; }
|
||
.vw-pay-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: .8rem;
|
||
padding: .6rem .8rem; border-radius: 10px; background: rgba(255,255,255,.03); border: 1px solid var(--border); font-size: .87rem;
|
||
}
|
||
.vw-pay-datum { color: var(--text-muted); flex: 1 1 auto; }
|
||
.vw-pay-betrag { font-weight: 700; }
|
||
.vw-pay-status { font-size: .74rem; font-weight: 700; padding: .18rem .55rem; border-radius: 999px; white-space: nowrap; }
|
||
.vw-pay-status.status-completed { background: rgba(52,211,153,.15); color: #5eeaa7; }
|
||
.vw-pay-status.status-failed { background: rgba(217,74,92,.15); color: #ff9a9a; }
|
||
.vw-pay-status.status-refunded, .vw-pay-status.status-reversed { background: rgba(56,189,248,.15); color: #38bdf8; }
|
||
|
||
.vw-leer { color: var(--text-muted); font-size: .9rem; padding: 1rem 0; text-align: center; }
|
||
|
||
/* =====================================================================
|
||
Feste Übersichts-Kisten auf der Hauptseite (Nutzer-Wunsch 04.08.2026:
|
||
"alle diese Fenster die nur dann erscheinen wenn ein Abonnent da ist,
|
||
sollen ein festes Fenster bekommen ... man muss draufklicken um zu
|
||
sehen wer was hat, wie weit ist"). Statt Fortschritt/Prämien/Zahlungen
|
||
nur je EINZELNEM Supporter in dessen Detailansicht zu zeigen, gibt es
|
||
jetzt drei permanent sichtbare, aufklappbare Kisten mit der Übersicht
|
||
über ALLE Supporter gleichzeitig — Kopfzeile zeigt die Kurzfassung
|
||
(Kennzahlen-Chips) immer, der Klick klappt die volle Liste auf.
|
||
===================================================================== */
|
||
.vw-overview-box { margin-bottom: 1.4rem; padding: 0; }
|
||
.vw-overview-head {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||
padding: 1.1rem 1.4rem; cursor: pointer; user-select: none;
|
||
}
|
||
.vw-overview-title { display: flex; align-items: center; gap: .6rem; font-weight: 800; font-size: 1rem; flex: 1 1 auto; }
|
||
.vw-overview-summary { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||
.vw-overview-chip {
|
||
padding: .32rem .75rem; border-radius: 999px; font-size: .76rem; font-weight: 700;
|
||
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap;
|
||
}
|
||
.vw-overview-chip.warn { background: rgba(56,189,248,.14); border-color: rgba(56,189,248,.4); color: #7dd3fc; }
|
||
.vw-overview-chip.gefahr { background: rgba(217,74,92,.14); border-color: rgba(217,74,92,.4); color: #ff9a9a; }
|
||
.vw-overview-toggle {
|
||
font-size: .82rem; color: #7dd3fc; display: flex; align-items: center; gap: .35rem; font-weight: 700; flex: none;
|
||
}
|
||
.vw-overview-toggle .pfeil { display: inline-block; transition: transform .2s ease; }
|
||
.vw-overview-box.offen .vw-overview-toggle .pfeil { transform: rotate(180deg); }
|
||
.vw-overview-body { display: none; padding: 0 1.4rem 1.4rem; border-top: 1px solid var(--border); max-height: 480px; overflow-y: auto; }
|
||
.vw-overview-box.offen .vw-overview-body { display: block; padding-top: 1.1rem; animation: vw-fade-in .25s ease-out; }
|
||
|
||
.vw-fortschritt-row {
|
||
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .7rem .5rem; border-radius: 10px;
|
||
cursor: pointer; transition: background .15s ease;
|
||
}
|
||
.vw-fortschritt-row:hover { background: rgba(139,92,246,.08); }
|
||
.vw-fortschritt-row + .vw-fortschritt-row { border-top: 1px dashed var(--border); }
|
||
|
||
/* Sanftes Einblenden von Kennzahlen/Zeilen — fühlt sich "lebendig" an
|
||
statt dass alles einfach ruckartig erscheint. */
|
||
@keyframes vw-fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||
.vw-stat, .vw-table tbody tr { animation: vw-fade-in .35s ease-out backwards; }
|
||
.vw-table tbody tr:nth-child(1) { animation-delay: .02s; }
|
||
.vw-table tbody tr:nth-child(2) { animation-delay: .05s; }
|
||
.vw-table tbody tr:nth-child(3) { animation-delay: .08s; }
|
||
.vw-table tbody tr:nth-child(4) { animation-delay: .11s; }
|
||
.vw-table tbody tr:nth-child(5) { animation-delay: .14s; }
|
||
@media (prefers-reduced-motion: reduce) { .vw-stat, .vw-table tbody tr { animation: none; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="vw-logo-bg" aria-hidden="true">
|
||
<div class="vw-logo-tile"></div>
|
||
<div class="vw-logo-emblem"></div>
|
||
</div>
|
||
<div class="vw-aurora" aria-hidden="true"><span class="g1"></span><span class="g2"></span><span class="g3"></span><span class="g4"></span></div>
|
||
<div id="site-header"></div>
|
||
|
||
<main>
|
||
<section class="hero" id="page-hero" style="display:none;">
|
||
<div class="container">
|
||
<div class="vw-hero-crest" aria-hidden="true"></div>
|
||
<span class="vw-hero-eyebrow">🔒 Privater Bereich</span>
|
||
<h1 class="vw-hero-titel">DogiCrew-Verwaltung</h1>
|
||
<p class="vw-hero-gruss" id="hero-gruss">Willkommen zurück.</p>
|
||
<p class="vw-hero-sub">Supporter, Prämien und Kollektionen — alles auf einen Blick, nur für dich sichtbar.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== Login-Gate =====
|
||
Nutzer-Wunsch 04.08.2026 (6. Runde): (1) der "Verwaltung öffnen"-Knopf
|
||
war auf schmalen Bildschirmen riesig (umgebrochen auf zwei Zeilen) —
|
||
jetzt ein deutlich kompakterer .vw-gate-btn-Stil statt des vollen
|
||
.btn-holo-Formats. (2) VanVan hatte bisher keine eigene Zeile/Kiste,
|
||
beide teilten sich ein einziges Eingabefeld — jetzt zwei getrennte,
|
||
gleichwertige Kisten nebeneinander (auf Mobilgeräten untereinander),
|
||
jede mit eigenem Feld + eigenem Knopf. Dogis Kiste behält die
|
||
bisherige Babyblau/Lila-Optik, VanVans Kiste bekommt bewusst eine
|
||
eigene, femininere Optik (Rosé/Lavendel/Gold, Blüten-Icon statt
|
||
Schloss) statt einer bloßen Farbkopie. Beide Formulare rufen
|
||
weiterhin denselben Login-Endpunkt auf — technisch unverändert,
|
||
nur die Zugangscodes entscheiden serverseitig, wer sich anmeldet. -->
|
||
<section id="gate-container">
|
||
<div class="container vw-gate-wrap">
|
||
<div class="vw-gate-head">
|
||
<div class="vw-gate-schloss" aria-hidden="true"></div>
|
||
<span class="vw-gate-eyebrow">Gesperrter Bereich</span>
|
||
<h1 class="vw-gate-titel">DogiCrew-Verwaltung</h1>
|
||
<p class="vw-gate-sub">Zugangscode erforderlich — gilt danach 24 Stunden.</p>
|
||
</div>
|
||
|
||
<div class="vw-gate-grid">
|
||
<form id="code-gate-dogi" class="vw-gate-card vw-gate-card--dogi" novalidate>
|
||
<span class="vw-gate-card-icon vw-gate-card-icon--dogi" aria-hidden="true"></span>
|
||
<h2 class="vw-gate-card-name">Dogi</h2>
|
||
<label for="code-input-dogi">Zugangscode</label>
|
||
<input id="code-input-dogi" name="code" type="password" autocomplete="off" required autofocus />
|
||
<button class="btn-holo vw-gate-btn" type="submit">
|
||
<span class="btn-holo-text">💙 Öffnen</span>
|
||
</button>
|
||
<p class="vw-fehler" id="gate-fehler-dogi"></p>
|
||
</form>
|
||
|
||
<form id="code-gate-vanvan" class="vw-gate-card vw-gate-card--vanvan" novalidate>
|
||
<span class="vw-gate-card-icon vw-gate-card-icon--vanvan" aria-hidden="true"></span>
|
||
<h2 class="vw-gate-card-name">VanVan</h2>
|
||
<label for="code-input-vanvan">Zugangscode</label>
|
||
<input id="code-input-vanvan" name="code" type="password" autocomplete="off" required />
|
||
<button class="btn-holo vw-gate-btn vw-gate-btn--vanvan" type="submit">
|
||
<span class="btn-holo-text">💗 Öffnen</span>
|
||
</button>
|
||
<p class="vw-fehler" id="gate-fehler-vanvan"></p>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== Session-Leiste ===== -->
|
||
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
|
||
<div class="container">
|
||
<div class="vw-session-bar vw-shine-card vw-noise">
|
||
<span class="vw-owner-badge" id="role-badge"><span class="krone">👑</span> Owner</span>
|
||
<div class="vw-session-right">
|
||
<span class="vw-uhrzeit"><span class="vw-live-dot" aria-hidden="true"></span> Aktualisiert: <strong id="letzte-aktualisierung">–</strong></span>
|
||
<button class="btn btn-outline vw-logout-btn" id="pwa-install-btn" type="button" style="display:none;">📲 Als eigene App installieren</button>
|
||
<button class="btn btn-outline vw-logout-btn" id="logout-btn" type="button">Abmelden</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== Kennzahlen-Übersicht ===== -->
|
||
<section class="section-tight" id="vw-stats-root" style="display:none;padding-top:0;">
|
||
<div class="container">
|
||
<div class="vw-stats">
|
||
<div class="vw-stat vw-stat--violet"><span class="vw-stat-icon" aria-hidden="true">👥</span><div><div class="label">Gesamt Supporter</div><div class="value" id="stat-gesamt">–</div></div></div>
|
||
<div class="vw-stat vw-stat--emerald"><span class="vw-stat-icon" aria-hidden="true">💚</span><div><div class="label">Aktive Abos</div><div class="value" id="stat-aktiv">–</div></div></div>
|
||
<div class="vw-stat vw-stat--blau"><span class="vw-stat-icon" aria-hidden="true">💰</span><div><div class="label">Monatsumsatz</div><div class="value" id="stat-umsatz">–</div></div></div>
|
||
<div class="vw-stat vw-stat--rose"><span class="vw-stat-icon" aria-hidden="true">✨</span><div><div class="label">Neu (30 Tage)</div><div class="value" id="stat-neu">–</div></div></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== "Event des Jahres" (Startseite) — 20.08.2026, Nutzer-Wunsch:
|
||
"will ich in der admin seite das selbst gestalten können, mit bild
|
||
und text und am besten auch einen link". Nur sichtbar mit
|
||
EVENTS_MANAGE-Berechtigung (Owner immer). Titel/Text werden bewusst
|
||
NUR auf Deutsch eingegeben — server-internal/lib/translate.js
|
||
übersetzt beim Speichern automatisch nach en/fr/pt (de-CH bekommt
|
||
denselben deutschen Text, siehe Kommentar dort: Dialekt lässt sich
|
||
nicht maschinell übersetzen). Zeigt IMMER genau 2 feste Slots, kein
|
||
wachsender Katalog. -->
|
||
<section class="section-tight" id="events-root" style="display:none;padding-top:0;">
|
||
<div class="container">
|
||
<h2 class="text-center" style="margin-bottom:.3rem;">🏆 Event des Jahres</h2>
|
||
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
|
||
Erscheint auf der Startseite unter den 3 Welt-Kacheln. Titel/Text bitte auf Deutsch
|
||
eintippen — die anderen Sprachen werden beim Speichern automatisch übersetzt
|
||
(maschinell, daher nie ganz so gut wie eine echte Übersetzung).
|
||
</p>
|
||
<div class="grid grid-2" id="events-grid" style="gap:1.2rem;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== "Spezielles Live-Event" (Streamplan-Seite) — 20.08.2026,
|
||
Nutzer-Wunsch: "spezielle live events da auch eintragen ... so dass
|
||
da eine zweite uhr erscheint wo die zeit dann für dieses spezielle
|
||
event läuft." Anders als "Event des Jahres" hier ein echtes
|
||
Datum+Uhrzeit statt eines freien Textes -- daraus baut
|
||
streamplan.html einen echten Countdown. Nur sichtbar mit derselben
|
||
EVENTS_MANAGE-Berechtigung. -->
|
||
<section class="section-tight" id="special-event-root" style="display:none;padding-top:0;">
|
||
<div class="container">
|
||
<h2 class="text-center" style="margin-bottom:.3rem;">⚡ Spezielles Live-Event</h2>
|
||
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
|
||
Erscheint als zweite Uhr auf der Streamplan-Seite, sobald aktiviert. Titel bitte auf
|
||
Deutsch eintippen, wird beim Speichern automatisch übersetzt.
|
||
</p>
|
||
<div class="card vw-card vw-shine-card vw-noise" style="max-width:520px;margin:0 auto;padding:1.5rem;">
|
||
<div class="field">
|
||
<label for="se-titel">Titel (Deutsch)</label>
|
||
<input type="text" id="se-titel" maxlength="120" placeholder="z. B. Großer Q&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"></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>
|
||
<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=20260821k"></script>
|
||
<script src="assets/js/data-scouts.js?v=20260821k"></script>
|
||
<script src="assets/js/data-creator.js?v=20260821k"></script>
|
||
<script src="assets/js/data-manager.js?v=20260821k"></script>
|
||
<script src="assets/js/main.js?v=20260821k"></script>
|
||
<script src="assets/js/admin-auth.js?v=20260821k"></script>
|
||
<script>
|
||
(function () {
|
||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||
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", 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" };
|
||
|
||
function testimonialKarteHtml(t) {
|
||
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-testimonial-id="${t.id}">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
|
||
<div>
|
||
<strong>${escapeHtml(t.name)}</strong>${t.tiktokUsername ? ` <span class="small" style="color:var(--text-muted);">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
|
||
<div class="small" style="color:var(--text-muted);">${TESTIMONIAL_STATUS_LABELS[t.status] || t.status} · eingereicht am ${fmtDatum(t.createdAt)}</div>
|
||
</div>
|
||
<div class="btn-row" style="gap:.5rem;">
|
||
${t.status !== "freigegeben" ? `<button type="button" class="vw-refresh-btn testimonial-freigeben-btn">Freigeben</button>` : ""}
|
||
${t.status !== "abgelehnt" ? `<button type="button" class="vw-refresh-btn testimonial-ablehnen-btn">Ablehnen</button>` : ""}
|
||
<button type="button" class="vw-refresh-btn testimonial-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
|
||
</div>
|
||
</div>
|
||
<p style="margin:.8rem 0 0;">${escapeHtml(t.nachricht)}</p>
|
||
</div>`;
|
||
}
|
||
|
||
async function ladeUndRenderTestimonials() {
|
||
const listeEl = document.getElementById("testimonials-liste");
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/list", {});
|
||
if (!res.ok) {
|
||
listeEl.innerHTML = `<p class="vw-leer">Stimmen konnten nicht geladen werden.</p>`;
|
||
return;
|
||
}
|
||
const liste = res.testimonials || [];
|
||
listeEl.innerHTML = liste.length ? liste.map(testimonialKarteHtml).join("") : `<p class="vw-leer">Noch keine Einreichungen.</p>`;
|
||
|
||
const aktion = async (btn, status) => {
|
||
const id = btn.closest("[data-testimonial-id]").dataset.testimonialId;
|
||
btn.disabled = true;
|
||
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/status", { id, status });
|
||
await ladeUndRenderTestimonials();
|
||
};
|
||
listeEl.querySelectorAll(".testimonial-freigeben-btn").forEach((btn) => btn.addEventListener("click", () => aktion(btn, "freigegeben")));
|
||
listeEl.querySelectorAll(".testimonial-ablehnen-btn").forEach((btn) => btn.addEventListener("click", () => aktion(btn, "abgelehnt")));
|
||
listeEl.querySelectorAll(".testimonial-loeschen-btn").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
if (!confirm("Diese Einreichung unwiderruflich löschen?")) return;
|
||
const id = btn.closest("[data-testimonial-id]").dataset.testimonialId;
|
||
btn.disabled = true;
|
||
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/delete", { id });
|
||
await ladeUndRenderTestimonials();
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ===== "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", datei);
|
||
const session = window.AdminAuth.getSession();
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/admin/team/upload-photo`, {
|
||
method: "POST",
|
||
headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {},
|
||
body: form,
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
statusEl.textContent = data.error || "Foto-Upload fehlgeschlagen.";
|
||
statusEl.style.color = "#ff6b6b";
|
||
return;
|
||
}
|
||
teamHochgeladenesFoto = data.url;
|
||
const vorschau = document.getElementById("team-foto-vorschau");
|
||
vorschau.src = data.url;
|
||
vorschau.style.display = "";
|
||
statusEl.textContent = "Foto hochgeladen — jetzt noch auf „Speichern“ klicken.";
|
||
} catch {
|
||
statusEl.textContent = "Foto-Upload fehlgeschlagen (keine Verbindung).";
|
||
statusEl.style.color = "#ff6b6b";
|
||
}
|
||
});
|
||
|
||
document.getElementById("team-speichern").addEventListener("click", async () => {
|
||
const statusEl = document.getElementById("team-status");
|
||
const name = document.getElementById("team-name").value.trim();
|
||
const rolle = document.getElementById("team-rolle").value.trim();
|
||
if (!name || !rolle) {
|
||
statusEl.textContent = "Name und Rolle sind Pflicht.";
|
||
statusEl.style.color = "#ff6b6b";
|
||
return;
|
||
}
|
||
const btn = document.getElementById("team-speichern");
|
||
btn.disabled = true;
|
||
statusEl.textContent = "Wird gespeichert und übersetzt …";
|
||
statusEl.style.color = "";
|
||
const id = document.getElementById("team-id").value || undefined;
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/save", {
|
||
id,
|
||
kategorie: document.getElementById("team-kategorie").value,
|
||
name,
|
||
rolle,
|
||
foto: teamHochgeladenesFoto,
|
||
tiktok: document.getElementById("team-tiktok").value.trim(),
|
||
bio: document.getElementById("team-bio").value.trim(),
|
||
zitat: document.getElementById("team-zitat").value.trim(),
|
||
intro: document.getElementById("team-intro").value.trim(),
|
||
bioHtml: document.getElementById("team-bio-html").value.trim(),
|
||
extraCtaHref: document.getElementById("team-extra-cta-href").value.trim(),
|
||
extraCtaLabel: document.getElementById("team-extra-cta-label").value.trim(),
|
||
rang: document.getElementById("team-rang").value,
|
||
rangLabel: document.getElementById("team-rang-label").value.trim(),
|
||
reihenfolge: document.getElementById("team-reihenfolge").value,
|
||
status: document.getElementById("team-aktiv").checked ? "aktiv" : "pausiert",
|
||
});
|
||
btn.disabled = false;
|
||
if (!res.ok) {
|
||
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
|
||
statusEl.style.color = "#ff6b6b";
|
||
return;
|
||
}
|
||
teamFormularZuruecksetzen();
|
||
await ladeUndRenderTeam();
|
||
// WICHTIG: erst NACH teamFormularZuruecksetzen() setzen -- die Funktion
|
||
// leert team-status als Teil des normalen Zurücksetzens, ein Erfolgs-
|
||
// text davor wäre also sofort wieder verschwunden, bevor er je zu sehen
|
||
// war (mit Playwright gefunden: der Status blieb nach dem Speichern
|
||
// immer leer).
|
||
document.getElementById("team-status").textContent = "✅ Gespeichert.";
|
||
document.getElementById("team-status").style.color = "#baf7d6";
|
||
});
|
||
|
||
document.getElementById("team-filter-bar").addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".filter-btn");
|
||
if (!btn) return;
|
||
teamAktiverFilter = btn.dataset.teamFilter;
|
||
document.querySelectorAll("#team-filter-bar .filter-btn").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
teamListeZeichnen();
|
||
});
|
||
|
||
teamRangFelderAnpassen();
|
||
|
||
/* "Bestehende Mitglieder importieren" (21.08.2026, Runde 2, Nutzer-Wunsch:
|
||
"wenn ich eine änderung mache ist es auch für jeden für die die schon
|
||
da sind und die neuen"). Liest VanVan/Diene/Funny/Miss/Marina/Ghost
|
||
(window.MODIS, VanVan bewusst ausgenommen -- sie hat ihre eigene,
|
||
abweichend gebaute Hervorhebungs-Karte + eigene Seite vanvan.html,
|
||
passt nicht ins generische Schema), Patrick/Bananenstift
|
||
(window.SCOUTS) sowie window.CREATORS/MANAGERS (aktuell leer) aus und
|
||
schickt jede Person 1:1 an /admin/team/import. Übernimmt die
|
||
VORHANDENEN, von Hand geschriebenen Übersetzungen -- KEINE erneute
|
||
Maschinenübersetzung. Mehrfach klickbar (idempotent, siehe
|
||
routes/team.js): bereits importierte Personen werden serverseitig
|
||
einfach übersprungen, nie dupliziert. */
|
||
function zuSprachObjekt(feld) {
|
||
if (!feld) return null;
|
||
if (typeof feld === "string") return { de: feld, "de-CH": feld, en: feld, fr: feld, pt: feld };
|
||
if (typeof feld === "object" && feld.de) return feld;
|
||
return null;
|
||
}
|
||
function slugAusNameClient(name) {
|
||
return String(name || "")
|
||
.toLowerCase()
|
||
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
}
|
||
|
||
document.getElementById("team-import-btn").addEventListener("click", async () => {
|
||
const btn = document.getElementById("team-import-btn");
|
||
const statusEl = document.getElementById("team-import-status");
|
||
btn.disabled = true;
|
||
statusEl.textContent = "Importiere …";
|
||
|
||
const quellen = [
|
||
...(window.MODIS || []).filter((p) => p.name !== "VanVan").map((p) => ({ p, kategorie: "modi" })),
|
||
...(window.SCOUTS || []).map((p) => ({ p, kategorie: "scout" })),
|
||
...(window.CREATORS || []).map((p) => ({ p, kategorie: "creator" })),
|
||
...(window.MANAGERS || []).map((p) => ({ p, kategorie: "manager" })),
|
||
];
|
||
|
||
let importiert = 0, uebersprungen = 0, fehler = 0;
|
||
for (const { p, kategorie } of quellen) {
|
||
const rolle = zuSprachObjekt(p.role);
|
||
if (!rolle) { fehler++; continue; } // Pflichtfeld fehlt -- diese Person überspringen, nicht alle abbrechen
|
||
const payload = {
|
||
kategorie,
|
||
name: p.name,
|
||
slug: p.slug || slugAusNameClient(p.name),
|
||
rolle,
|
||
foto: p.photo || p.avatar || "",
|
||
tiktok: p.social?.tiktok || null,
|
||
bio: zuSprachObjekt(p.bio),
|
||
intro: zuSprachObjekt(p.intro),
|
||
bioHtml: zuSprachObjekt(p.bioHtml),
|
||
zitat: zuSprachObjekt(p.quote),
|
||
rang: p.rank || null,
|
||
rangLabel: zuSprachObjekt(p.rankLabel),
|
||
extraCta: p.extraCta?.href ? { href: p.extraCta.href, label: zuSprachObjekt(p.extraCta.label) } : null,
|
||
};
|
||
try {
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/import", payload);
|
||
if (res.ok && res.uebersprungen) uebersprungen++;
|
||
else if (res.ok) importiert++;
|
||
else fehler++;
|
||
} catch {
|
||
fehler++;
|
||
}
|
||
}
|
||
|
||
statusEl.textContent = `✅ ${importiert} importiert, ${uebersprungen} schon vorhanden` + (fehler ? `, ${fehler} übersprungen (fehlerhaft)` : "") + ".";
|
||
btn.disabled = false;
|
||
await ladeUndRenderTeam();
|
||
});
|
||
|
||
/* ===== "Kategorien & Seitentitel" (21.08.2026) =====
|
||
Ein Kärtchen pro Kategorie mit zwei Feldern (Kategorie-Name,
|
||
Seitentitel) -- speichert beide über denselben admin/site-texts/save-
|
||
Endpunkt wie zwei unabhängige Overrides. Aktualisiert nach dem
|
||
Speichern sofort die Kategorie-Badges in der Team-Liste, damit man den
|
||
Effekt nicht erst durch Neuladen sehen muss. */
|
||
const SITE_TEXT_KATEGORIEN = [
|
||
{ kat: "modi", label: "Modi", kategorieKey: "kategorie_modi", seitentitelKey: "seitentitel_modi", kategoriePlatzhalter: "Modi", seitentitelPlatzhalter: "Das Modi-Team" },
|
||
{ kat: "scout", label: "Scout", kategorieKey: "kategorie_scout", seitentitelKey: "seitentitel_scout", kategoriePlatzhalter: "Scouts", seitentitelPlatzhalter: "Mein Scout-Team" },
|
||
{ kat: "creator", label: "Creator", kategorieKey: "kategorie_creator", seitentitelKey: "seitentitel_creator", kategoriePlatzhalter: "Creator", seitentitelPlatzhalter: "Creator im Team" },
|
||
{ kat: "manager", label: "Manager", kategorieKey: "kategorie_manager", seitentitelKey: "seitentitel_manager", kategoriePlatzhalter: "Manager", seitentitelPlatzhalter: "Weitere Manager" },
|
||
];
|
||
|
||
function siteTextKarteHtml(eintrag) {
|
||
return `<div class="card vw-card vw-shine-card vw-noise" style="padding:1.3rem;" data-site-text-kat="${eintrag.kat}">
|
||
<h3 style="margin-top:0;">${eintrag.label}</h3>
|
||
<div class="field">
|
||
<label>Kategorie-Name</label>
|
||
<input type="text" class="site-text-kategorie-input" maxlength="40" placeholder="${eintrag.kategoriePlatzhalter}" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Seitentitel</label>
|
||
<input type="text" class="site-text-seitentitel-input" maxlength="100" placeholder="${eintrag.seitentitelPlatzhalter}" />
|
||
</div>
|
||
<button type="button" class="btn btn-outline site-text-speichern-btn" style="width:100%;">Speichern</button>
|
||
<p class="small site-text-status" style="margin-top:.5rem;"></p>
|
||
</div>`;
|
||
}
|
||
|
||
async function teamKategorieLabelsAktualisieren(texteVorhanden) {
|
||
let texte = texteVorhanden;
|
||
if (!texte) {
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
|
||
const data = await res.json();
|
||
texte = data.ok ? data.texte || {} : {};
|
||
} catch {
|
||
texte = {};
|
||
}
|
||
}
|
||
Object.keys(TEAM_KATEGORIE_LABELS).forEach((kat) => {
|
||
const override = texte[`kategorie_${kat}`]?.de;
|
||
if (override) TEAM_KATEGORIE_LABELS[kat] = override;
|
||
});
|
||
document.querySelectorAll("#team-filter-bar .filter-btn[data-team-filter]").forEach((btn) => {
|
||
const kat = btn.dataset.teamFilter;
|
||
if (TEAM_KATEGORIE_LABELS[kat]) btn.textContent = TEAM_KATEGORIE_LABELS[kat];
|
||
});
|
||
}
|
||
|
||
async function ladeUndRenderSiteTexte() {
|
||
const grid = document.getElementById("site-texts-grid");
|
||
grid.innerHTML = SITE_TEXT_KATEGORIEN.map(siteTextKarteHtml).join("");
|
||
|
||
let aktuelleTexte = {};
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
|
||
const data = await res.json();
|
||
if (data.ok) aktuelleTexte = data.texte || {};
|
||
} catch {
|
||
/* Formular bleibt leer (= eingebauter Standardtext), Speichern funktioniert trotzdem. */
|
||
}
|
||
|
||
SITE_TEXT_KATEGORIEN.forEach((eintrag) => {
|
||
const karte = grid.querySelector(`[data-site-text-kat="${eintrag.kat}"]`);
|
||
karte.querySelector(".site-text-kategorie-input").value = aktuelleTexte[eintrag.kategorieKey]?.de || "";
|
||
karte.querySelector(".site-text-seitentitel-input").value = aktuelleTexte[eintrag.seitentitelKey]?.de || "";
|
||
|
||
karte.querySelector(".site-text-speichern-btn").addEventListener("click", async () => {
|
||
const statusEl = karte.querySelector(".site-text-status");
|
||
const btn = karte.querySelector(".site-text-speichern-btn");
|
||
btn.disabled = true;
|
||
statusEl.textContent = "Wird gespeichert …";
|
||
statusEl.style.color = "";
|
||
const kategorieText = karte.querySelector(".site-text-kategorie-input").value.trim();
|
||
const seitentitelText = karte.querySelector(".site-text-seitentitel-input").value.trim();
|
||
const [r1, r2] = await Promise.all([
|
||
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.kategorieKey, text: kategorieText }),
|
||
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.seitentitelKey, text: seitentitelText }),
|
||
]);
|
||
btn.disabled = false;
|
||
if (!r1.ok || !r2.ok) {
|
||
statusEl.textContent = r1.error || r2.error || "Speichern fehlgeschlagen.";
|
||
statusEl.style.color = "#ff6b6b";
|
||
return;
|
||
}
|
||
statusEl.textContent = "✅ Gespeichert — wirkt sofort auf der Website.";
|
||
statusEl.style.color = "#baf7d6";
|
||
await teamKategorieLabelsAktualisieren();
|
||
teamListeZeichnen();
|
||
});
|
||
});
|
||
|
||
await teamKategorieLabelsAktualisieren(aktuelleTexte);
|
||
teamListeZeichnen();
|
||
}
|
||
|
||
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
|
||
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
|
||
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der
|
||
// eigenen Kiste.
|
||
function bindeGateFormular(formId, inputId, fehlerId) {
|
||
document.getElementById(formId).addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fehlerEl = document.getElementById(fehlerId);
|
||
fehlerVerstecken(fehlerEl);
|
||
const code = document.getElementById(inputId).value.trim();
|
||
if (!code) return;
|
||
const res = await window.AdminAuth.doLogin(API_BASE_URL, code);
|
||
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Zugangscode falsch.");
|
||
await pruefeSession();
|
||
});
|
||
}
|
||
bindeGateFormular("code-gate-dogi", "code-input-dogi", "gate-fehler-dogi");
|
||
bindeGateFormular("code-gate-vanvan", "code-input-vanvan", "gate-fehler-vanvan");
|
||
|
||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||
await window.AdminAuth.doLogout(API_BASE_URL);
|
||
zeigeGate();
|
||
});
|
||
|
||
function berechneStats(supporters) {
|
||
const gesamt = supporters.length;
|
||
const aktiv = supporters.filter((s) => s.sub_status === "active").length;
|
||
const umsatz = (aktiv * 4.99).toFixed(2).replace(".", ",");
|
||
const vor30Tagen = Date.now() - 30 * 86400000;
|
||
const neu = supporters.filter((s) => s.created_at && new Date(s.created_at).getTime() >= vor30Tagen).length;
|
||
document.getElementById("stat-gesamt").textContent = gesamt;
|
||
document.getElementById("stat-aktiv").textContent = aktiv;
|
||
document.getElementById("stat-umsatz").innerHTML = `${umsatz} € <small>geschätzt</small>`;
|
||
document.getElementById("stat-neu").textContent = neu;
|
||
}
|
||
|
||
// Zeigt pro Filter-Option direkt an, wie viele Supporter zutreffen
|
||
// ("Aktiv (3)" statt einfach nur "Aktiv") — man sieht auf einen Blick,
|
||
// was sich hinter jedem Filter verbirgt, ohne ihn erst anklicken zu müssen.
|
||
const FILTER_PRUEFUNG = {
|
||
alle: () => true,
|
||
aktiv: (r) => r.sub_status === "active",
|
||
gekuendigt: (r) => r.sub_status === "cancelled",
|
||
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
|
||
monat_6: (r) => (r.total_paid_months || 0) >= 6,
|
||
monat_12: (r) => (r.total_paid_months || 0) >= 12,
|
||
monat_18: (r) => (r.total_paid_months || 0) >= 18,
|
||
monat_24: (r) => (r.total_paid_months || 0) >= 24,
|
||
monat_30: (r) => (r.total_paid_months || 0) >= 30,
|
||
};
|
||
const FILTER_LABELS = {
|
||
alle: "Alle", aktiv: "Aktiv", gekuendigt: "Gekündigt",
|
||
zahlung_fehlgeschlagen: "Zahlung fehlgeschlagen",
|
||
monat_6: "Ab Monat 6", monat_12: "Ab Monat 12", monat_18: "Ab Monat 18",
|
||
monat_24: "Ab Monat 24", monat_30: "Ab Monat 30",
|
||
};
|
||
let currentFilter = "alle";
|
||
function aktualisiereFilterZahlen() {
|
||
document.querySelectorAll("#filter-list li").forEach((li) => {
|
||
const wert = li.dataset.value;
|
||
const pruef = FILTER_PRUEFUNG[wert];
|
||
const anzahl = pruef ? alleSupporter.filter(pruef).length : 0;
|
||
li.textContent = `${FILTER_LABELS[wert] || wert} (${anzahl})`;
|
||
if (wert === currentFilter) {
|
||
document.getElementById("filter-btn-label").textContent = li.textContent;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Eigenes Dropdown (siehe HTML-Kommentar oben): Knopf öffnet/schließt
|
||
// die Liste, Klick auf einen Eintrag setzt currentFilter + rendert neu.
|
||
(function initFilterDropdown() {
|
||
const wrap = document.getElementById("filter-dropdown");
|
||
const btn = document.getElementById("filter-btn");
|
||
const list = document.getElementById("filter-list");
|
||
// "Portal"-Muster (siehe CSS-Kommentar bei .vw-select-list): die
|
||
// Liste wird beim allerersten Öffnen aus ihrer ursprünglichen Stelle
|
||
// im DOM herausgelöst und direkt an <body> gehängt — dadurch liegt
|
||
// sie außerhalb JEDER Karten-Verschachtelung/Stacking-Context-Kette
|
||
// und das Compositing-Problem kann gar nicht mehr auftreten. Position
|
||
// ist "absolute", also dokument-relativ (inkl. Scroll-Offset), nicht
|
||
// viewport-relativ wie bei "fixed" — deshalb + window.scrollX/Y.
|
||
let portalisiert = false;
|
||
function positioniere() {
|
||
if (!portalisiert) { document.body.appendChild(list); portalisiert = true; }
|
||
const rect = btn.getBoundingClientRect();
|
||
list.style.top = `${rect.bottom + window.scrollY + 8}px`;
|
||
list.style.left = `${rect.left + window.scrollX}px`;
|
||
list.style.minWidth = `${rect.width}px`;
|
||
}
|
||
function oeffnen() { positioniere(); wrap.classList.add("open"); list.hidden = false; btn.setAttribute("aria-expanded", "true"); }
|
||
function schliessen() { wrap.classList.remove("open"); list.hidden = true; btn.setAttribute("aria-expanded", "false"); }
|
||
btn.addEventListener("click", () => (list.hidden ? oeffnen() : schliessen()));
|
||
// Bei Scroll/Resize passt die Position nicht mehr zum Knopf — live
|
||
// neu berechnen statt schließen.
|
||
window.addEventListener("scroll", () => { if (!list.hidden) positioniere(); }, { passive: true, capture: true });
|
||
window.addEventListener("resize", () => { if (!list.hidden) positioniere(); });
|
||
list.addEventListener("click", (e) => {
|
||
const li = e.target.closest("li[data-value]");
|
||
if (!li) return;
|
||
currentFilter = li.dataset.value;
|
||
document.getElementById("filter-btn-label").textContent = li.textContent;
|
||
list.querySelectorAll("li").forEach((el) => {
|
||
const gewaehlt = el === li;
|
||
el.classList.toggle("selected", gewaehlt);
|
||
el.setAttribute("aria-selected", String(gewaehlt));
|
||
});
|
||
schliessen();
|
||
renderTabelle();
|
||
});
|
||
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) schliessen(); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") schliessen(); });
|
||
})();
|
||
|
||
function gefilterteSortierteListe() {
|
||
const filter = currentFilter;
|
||
const suchbegriff = document.getElementById("such-input").value.trim().toLowerCase();
|
||
let liste = alleSupporter.filter(FILTER_PRUEFUNG[filter] || FILTER_PRUEFUNG.alle);
|
||
if (suchbegriff) {
|
||
liste = liste.filter((s) =>
|
||
[s.display_name, s.email, s.tiktok_username].filter(Boolean).some((f) => f.toLowerCase().includes(suchbegriff))
|
||
);
|
||
}
|
||
const { feld, richtung } = sortierung;
|
||
liste = [...liste].sort((a, b) => {
|
||
let av = a[feld], bv = b[feld];
|
||
if (feld === "created_at") { av = av ? new Date(av).getTime() : 0; bv = bv ? new Date(bv).getTime() : 0; }
|
||
else if (feld === "total_paid_months") { av = av || 0; bv = bv || 0; }
|
||
else { av = String(av || "").toLowerCase(); bv = String(bv || "").toLowerCase(); }
|
||
if (av < bv) return richtung === "asc" ? -1 : 1;
|
||
if (av > bv) return richtung === "asc" ? 1 : -1;
|
||
return 0;
|
||
});
|
||
return liste;
|
||
}
|
||
|
||
async function ladeAlleSupporter() {
|
||
document.getElementById("detail-ansicht").style.display = "none";
|
||
document.getElementById("liste-ansicht").style.display = "";
|
||
const fehlerEl = document.getElementById("liste-fehler");
|
||
fehlerVerstecken(fehlerEl);
|
||
const refreshBtn = document.getElementById("refresh-btn");
|
||
refreshBtn.classList.add("lädt");
|
||
// Immer "alle" laden — Filtern/Suchen/Sortieren passiert danach
|
||
// komplett lokal, ohne erneuten Server-Roundtrip.
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/list", { filter: "alle" });
|
||
refreshBtn.classList.remove("lädt");
|
||
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Konnte Liste nicht laden.");
|
||
|
||
alleSupporter = res.supporters;
|
||
document.getElementById("letzte-aktualisierung").textContent = new Date().toLocaleTimeString("de-DE");
|
||
berechneStats(alleSupporter);
|
||
aktualisiereFilterZahlen();
|
||
renderTabelle();
|
||
renderFortschrittUebersicht();
|
||
await ladeUebersicht();
|
||
}
|
||
|
||
// ---------- Feste Übersichts-Kisten (Nutzer-Wunsch 04.08.2026) ----------
|
||
let uebersichtDaten = { premiums: [], payments: [] };
|
||
|
||
document.querySelectorAll(".vw-overview-head").forEach((head) => {
|
||
head.addEventListener("click", () => {
|
||
const box = document.getElementById(head.dataset.toggle);
|
||
const offen = box.classList.toggle("offen");
|
||
head.querySelector(".vw-overview-toggle").firstChild.textContent = offen ? "Verbergen " : "Anzeigen ";
|
||
});
|
||
});
|
||
|
||
async function ladeUebersicht() {
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/overview", {});
|
||
if (!res.ok) return; // still zurückhaltend — die Hauptliste funktioniert auch ohne
|
||
uebersichtDaten = { premiums: res.premiums || [], payments: res.payments || [] };
|
||
renderPraemienUebersicht();
|
||
renderZahlungenUebersicht();
|
||
}
|
||
|
||
const PRAEMIEN_AKTIONSBEDUERFTIG = ["wird_geprueft", "wird_vorbereitet", "versandbereit", "problem", "ersatz_wird_vorbereitet"];
|
||
|
||
function bindePraemienSpeichernKnoepfe(container) {
|
||
container.querySelectorAll("[data-save]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const row = btn.closest("[data-premium-id]");
|
||
const redeemStatus = row.querySelector(".status-select").value;
|
||
const trackingNumber = row.querySelector(".tracking-input").value.trim();
|
||
btn.disabled = true;
|
||
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
|
||
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
|
||
});
|
||
btn.disabled = false;
|
||
if (!r.ok) { alert(r.error || "Speichern fehlgeschlagen."); return; }
|
||
btn.textContent = "✓ Gespeichert";
|
||
setTimeout(() => ladeUebersicht(), 700);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderPraemienUebersicht() {
|
||
const alle = uebersichtDaten.premiums;
|
||
const zaehler = {};
|
||
alle.forEach((p) => { zaehler[p.redeem_status] = (zaehler[p.redeem_status] || 0) + 1; });
|
||
const chips = PRAEMIEN_AKTIONSBEDUERFTIG.filter((s) => zaehler[s]).map(
|
||
(s) => `<span class="vw-overview-chip ${s === "problem" ? "gefahr" : "warn"}">${zaehler[s]} × ${REDEEM_LABELS[s]}</span>`
|
||
);
|
||
chips.push(`<span class="vw-overview-chip">${alle.length} Prämien gesamt</span>`);
|
||
document.getElementById("praemien-summary").innerHTML = chips.join("");
|
||
|
||
const body = document.getElementById("praemien-body");
|
||
if (!alle.length) {
|
||
body.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet — der erste Meilenstein ist nach 6 bezahlten Monaten erreicht.</p>`;
|
||
return;
|
||
}
|
||
body.innerHTML = alle
|
||
.map(
|
||
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
|
||
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
|
||
<div class="vw-premium-info">
|
||
<strong>${escapeHtml(p.display_name)}</strong> — ${PREMIUM_TYPE_LABELS[p.premium_type] || p.premium_type} (Monat ${p.milestone_month})
|
||
<div class="small" style="color:var(--text-muted);">${escapeHtml(p.email)}${p.tracking_number ? " · Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
|
||
</div>
|
||
<div class="vw-premium-row-fields">
|
||
<span class="vw-premium-status status-${p.redeem_status}">${REDEEM_LABELS[p.redeem_status] || p.redeem_status}</span>
|
||
<select class="status-select">
|
||
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
|
||
</select>
|
||
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
|
||
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
bindePraemienSpeichernKnoepfe(body);
|
||
}
|
||
|
||
const CYCLE_MEILENSTEINE = [6, 12, 18, 24, 30];
|
||
function naechsterMeilensteinMonat(monat) {
|
||
return CYCLE_MEILENSTEINE.find((m) => monat < m) ?? null;
|
||
}
|
||
|
||
function renderFortschrittUebersicht() {
|
||
const aktive = alleSupporter.filter((s) => s.sub_status === "active" && s.progress);
|
||
if (!aktive.length) {
|
||
document.getElementById("fortschritt-summary").innerHTML = `<span class="vw-overview-chip">Keine aktiven Abos</span>`;
|
||
document.getElementById("fortschritt-body").innerHTML = `<p class="vw-leer">Keine aktiven Abos vorhanden.</p>`;
|
||
return;
|
||
}
|
||
const avgMonat = Math.round(aktive.reduce((sum, s) => sum + s.progress.currentCycleMonth, 0) / aktive.length);
|
||
const balDran = aktive.filter((s) => {
|
||
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
|
||
return naechster !== null && naechster - s.progress.currentCycleMonth <= 2;
|
||
});
|
||
document.getElementById("fortschritt-summary").innerHTML = `
|
||
<span class="vw-overview-chip">Ø Monat ${avgMonat}/30</span>
|
||
${balDran.length ? `<span class="vw-overview-chip warn">${balDran.length} kurz vor Meilenstein</span>` : ""}
|
||
<span class="vw-overview-chip">${aktive.length} aktiv</span>`;
|
||
|
||
const sortiert = [...aktive].sort((a, b) => {
|
||
const na = naechsterMeilensteinMonat(a.progress.currentCycleMonth);
|
||
const nb = naechsterMeilensteinMonat(b.progress.currentCycleMonth);
|
||
const ra = na === null ? 999 : na - a.progress.currentCycleMonth;
|
||
const rb = nb === null ? 999 : nb - b.progress.currentCycleMonth;
|
||
return ra - rb;
|
||
});
|
||
const body = document.getElementById("fortschritt-body");
|
||
body.innerHTML = sortiert
|
||
.map((s) => {
|
||
const prozent = Math.round((s.progress.currentCycleMonth / 30) * 100);
|
||
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
|
||
const rest = naechster === null ? null : naechster - s.progress.currentCycleMonth;
|
||
return `<div class="vw-fortschritt-row" data-goto="${s.id}">
|
||
<span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span>
|
||
<span class="vw-name-cell" style="flex:0 1 160px;">${escapeHtml(s.display_name)}</span>
|
||
<div class="vw-mini-progress-wrap" style="flex:1 1 160px;">
|
||
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
|
||
<span class="vw-mini-progress-label">Zyklus ${s.progress.cycleNumber} · Monat ${s.progress.currentCycleMonth}/30</span>
|
||
</div>
|
||
<span class="small" style="color:var(--text-muted);white-space:nowrap;">${rest !== null ? `noch ${rest} Mon. bis Meilenstein` : "Zyklus komplett 🏆"}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
body.querySelectorAll("[data-goto]").forEach((el) => el.addEventListener("click", () => zeigeDetail(el.dataset.goto)));
|
||
}
|
||
|
||
function renderZahlungenUebersicht() {
|
||
const alle = uebersichtDaten.payments;
|
||
const vor30Tagen = Date.now() - 30 * 86400000;
|
||
const letzte30 = alle.filter((p) => p.payment_status === "completed" && p.paid_at && new Date(p.paid_at).getTime() >= vor30Tagen);
|
||
const umsatz30 = letzte30.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0);
|
||
const fehlgeschlagen = alle.filter((p) => p.payment_status === "failed").length;
|
||
document.getElementById("zahlungen-summary").innerHTML = `
|
||
<span class="vw-overview-chip">${umsatz30.toFixed(2).replace(".", ",")} € (30 Tage)</span>
|
||
${fehlgeschlagen ? `<span class="vw-overview-chip gefahr">${fehlgeschlagen} fehlgeschlagen</span>` : ""}
|
||
<span class="vw-overview-chip">${alle.length} Zahlungen gesamt</span>`;
|
||
|
||
const body = document.getElementById("zahlungen-body");
|
||
body.innerHTML = !alle.length
|
||
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
|
||
: alle
|
||
.slice(0, 60)
|
||
.map((p) => {
|
||
const status = p.payment_status || "completed";
|
||
return `<div class="vw-pay-row">
|
||
<span class="vw-pay-datum">${fmtDatum(p.paid_at)} · ${escapeHtml(p.display_name)}</span>
|
||
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : "–"}</span>
|
||
<span class="vw-pay-status status-${status}">${PAYMENT_STATUS_LABELS[status] || status}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderTabelle() {
|
||
const liste = gefilterteSortierteListe();
|
||
const tbody = document.getElementById("supporter-tbody");
|
||
document.getElementById("anzahl-text").textContent = `${liste.length} von ${alleSupporter.length} Supporter${alleSupporter.length === 1 ? "" : "n"}`;
|
||
|
||
document.querySelectorAll("#supporter-tbody").length; // no-op, hält Linter ruhig
|
||
document.querySelectorAll("th.sortierbar").forEach((th) => {
|
||
th.classList.toggle("aktiv-sort", th.dataset.sort === sortierung.feld);
|
||
th.querySelector(".pfeil").textContent = th.dataset.sort === sortierung.feld ? (sortierung.richtung === "asc" ? "↑" : "↓") : "↕";
|
||
});
|
||
|
||
if (!liste.length) {
|
||
const text = alleSupporter.length
|
||
? "Keine Treffer für diese Filter-/Suchkombination."
|
||
: "Noch keine Einträge — sobald sich jemand als Supporter registriert, taucht er hier auf.";
|
||
tbody.innerHTML = `<tr><td colspan="8"><p class="vw-leer">${text}</p></td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = liste
|
||
.map((s) => {
|
||
const status = s.sub_status || "none";
|
||
const p = s.progress || { cycleNumber: 1, currentCycleMonth: 0 };
|
||
const prozent = Math.round((p.currentCycleMonth / 30) * 100);
|
||
return `<tr class="klickbar" data-id="${s.id}">
|
||
<td><span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span></td>
|
||
<td><span class="vw-name-cell">${escapeHtml(s.display_name)}</span></td>
|
||
<td>${s.tiktok_username ? "@" + escapeHtml(s.tiktok_username) : "–"}</td>
|
||
<td class="vw-mono">${escapeHtml(s.email)}</td>
|
||
<td><span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span></td>
|
||
<td>${s.total_paid_months ?? 0}</td>
|
||
<td>
|
||
<div class="vw-mini-progress-wrap">
|
||
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
|
||
<span class="vw-mini-progress-label">Zyklus ${p.cycleNumber} · Monat ${p.currentCycleMonth}/30</span>
|
||
</div>
|
||
</td>
|
||
<td>${fmtDatum(s.created_at)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
tbody.querySelectorAll("tr[data-id]").forEach((tr) => {
|
||
tr.addEventListener("click", () => zeigeDetail(tr.dataset.id));
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll("th.sortierbar").forEach((th) => {
|
||
th.addEventListener("click", () => {
|
||
const feld = th.dataset.sort;
|
||
if (sortierung.feld === feld) sortierung.richtung = sortierung.richtung === "asc" ? "desc" : "asc";
|
||
else sortierung = { feld, richtung: feld === "created_at" ? "desc" : "asc" };
|
||
renderTabelle();
|
||
});
|
||
});
|
||
// Der Filter selbst löst renderTabelle() jetzt direkt beim Klick im
|
||
// eigenen Dropdown aus (siehe initFilterDropdown oben) statt über ein
|
||
// "change"-Event auf einem <select>, das es nicht mehr gibt.
|
||
document.getElementById("such-input").addEventListener("input", renderTabelle);
|
||
document.getElementById("refresh-btn").addEventListener("click", ladeAlleSupporter);
|
||
|
||
// CSV-Export der aktuell sichtbaren (gefilterten/durchsuchten) Ansicht —
|
||
// z.B. für Buchhaltung/Übersicht außerhalb der Seite.
|
||
document.getElementById("export-btn").addEventListener("click", () => {
|
||
const liste = gefilterteSortierteListe();
|
||
const kopf = ["Name", "TikTok", "E-Mail", "Status", "Monate gesamt", "Zyklus", "Monat im Zyklus", "Seit"];
|
||
const zeilen = liste.map((s) => [
|
||
s.display_name || "", s.tiktok_username ? "@" + s.tiktok_username : "", s.email || "",
|
||
STATUS_LABELS[s.sub_status || "none"] || "", s.total_paid_months ?? 0,
|
||
s.progress?.cycleNumber ?? 1, s.progress?.currentCycleMonth ?? 0, fmtDatum(s.created_at),
|
||
]);
|
||
const csvZeile = (arr) => arr.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(";");
|
||
const csv = "" + [csvZeile(kopf), ...zeilen.map(csvZeile)].join("\r\n");
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `dogicrew-supporter-${new Date().toISOString().slice(0, 10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
function kopierenKnopf(text, id) {
|
||
return `<button type="button" class="vw-copy-btn" data-copy="${escapeHtml(text)}" id="${id}" title="Kopieren" aria-label="Kopieren">📋</button>`;
|
||
}
|
||
|
||
async function zeigeDetail(supporterId) {
|
||
document.getElementById("liste-ansicht").style.display = "none";
|
||
document.getElementById("detail-ansicht").style.display = "";
|
||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/detail", { supporterId });
|
||
if (!res.ok) { alert(res.error || "Konnte Details nicht laden."); return; }
|
||
|
||
const { supporter, subscription, premiums, payments } = res;
|
||
document.getElementById("detail-avatar").textContent = initialen(supporter.display_name);
|
||
document.getElementById("detail-name").textContent = supporter.display_name;
|
||
|
||
const chips = [
|
||
`📧 ${escapeHtml(supporter.email)} ${kopierenKnopf(supporter.email, "copy-email")}`,
|
||
supporter.tiktok_username
|
||
? `🎵 @${escapeHtml(supporter.tiktok_username)} ${kopierenKnopf("@" + supporter.tiktok_username, "copy-tiktok")}`
|
||
: null,
|
||
`<span class="vw-chip ${supporter.email_verified ? "verifiziert" : "unverifiziert"}" style="border:none;background:none;padding:0;">${supporter.email_verified ? "✅ E-Mail bestätigt" : "⚠️ E-Mail nicht bestätigt"}</span>`,
|
||
`🗓️ Mitglied seit ${fmtDatum(supporter.created_at)}`,
|
||
supporter.last_login_at ? `🕓 Zuletzt aktiv ${fmtDatum(supporter.last_login_at)}` : null,
|
||
].filter(Boolean);
|
||
document.getElementById("detail-chips").innerHTML = chips
|
||
.map((c) => (c.startsWith("<span") ? c : `<span class="vw-chip">${c}</span>`))
|
||
.join("");
|
||
|
||
const status = subscription?.status || "none";
|
||
document.getElementById("detail-status").innerHTML = `<span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span>`;
|
||
document.getElementById("detail-naechste").textContent = fmtDatum(subscription?.next_payment_at);
|
||
document.getElementById("detail-monate").textContent = subscription?.total_paid_months ?? 0;
|
||
const paypalId = subscription?.paypal_subscription_id;
|
||
document.getElementById("detail-paypal").innerHTML = paypalId
|
||
? `${escapeHtml(paypalId)} ${kopierenKnopf(paypalId, "copy-paypal")}`
|
||
: "–";
|
||
|
||
// Zusatzfelder je nach Situation: bei Kündigung "Zugang bis"/"Gekündigt
|
||
// am" ergänzen, statt sie zu verstecken (Nutzer-Wunsch: "nichts fehlt").
|
||
const grid = document.getElementById("detail-grid");
|
||
grid.querySelectorAll("[data-extra]").forEach((el) => el.remove());
|
||
const extras = [];
|
||
if (subscription?.cancelled_at) extras.push(["Gekündigt am", fmtDatum(subscription.cancelled_at)]);
|
||
if (subscription?.paid_period_end_at) extras.push(["Zugang bis", fmtDatum(subscription.paid_period_end_at)]);
|
||
if (subscription?.started_at) extras.push(["Abo läuft seit", fmtDatum(subscription.started_at)]);
|
||
extras.forEach(([label, value]) => {
|
||
const div = document.createElement("div");
|
||
div.dataset.extra = "1";
|
||
div.innerHTML = `<div class="vw-detail-label">${escapeHtml(label)}</div><div class="vw-detail-value">${escapeHtml(value)}</div>`;
|
||
grid.appendChild(div);
|
||
});
|
||
|
||
// Fortschritts-Kiste: wo steht dieser Supporter im 30-Monats-Zyklus
|
||
// (Nutzer-Wunsch 04.08.2026). progress/timeline kommen direkt vom
|
||
// Server (lib/supporter-cycle.js — dieselbe Rechenlogik wie im
|
||
// öffentlichen Supporter-Bereich, damit nichts auseinanderläuft).
|
||
const progressBox = document.getElementById("detail-progress-box");
|
||
if (subscription && res.progress && res.timeline) {
|
||
progressBox.style.display = "";
|
||
const { progress, timeline } = res;
|
||
const prozent = Math.round((timeline.currentCycleMonth / 30) * 100);
|
||
document.getElementById("detail-zyklus-badge").textContent = `Zyklus ${progress.cycleNumber}`;
|
||
document.getElementById("detail-progress-hero").innerHTML =
|
||
`${timeline.currentCycleMonth} <small>von 30 Monaten im aktuellen Zyklus</small>`;
|
||
document.getElementById("detail-progress-fill").style.width = `${prozent}%`;
|
||
document.getElementById("detail-progress-text").textContent =
|
||
`${progress.totalPaidMonths} Monate insgesamt bezahlt · ${prozent}% des aktuellen Zyklus geschafft`;
|
||
|
||
const celebrateEl = document.getElementById("detail-progress-celebrate");
|
||
if (progress.completedCycles > 0) {
|
||
celebrateEl.style.display = "";
|
||
celebrateEl.textContent = `🏆 Hat bereits ${progress.completedCycles} komplette Kollektion${progress.completedCycles > 1 ? "en" : ""} durchlaufen.`;
|
||
} else {
|
||
celebrateEl.style.display = "none";
|
||
}
|
||
|
||
document.getElementById("detail-progress-milestones").innerHTML = timeline.milestones
|
||
.map((m) => {
|
||
const klasse = m.reached ? "erreicht" : m.isNext ? "naechste" : "";
|
||
const statusText = m.reached ? "Freigeschaltet" : m.isNext ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
|
||
return `<div class="vw-progress-milestone ${klasse}">
|
||
<div class="med" aria-hidden="true">${m.reached ? "🎁" : m.isNext ? "✨" : "🔒"}</div>
|
||
<div class="m-monat">Monat ${m.month}</div>
|
||
<div class="m-titel">${PREMIUM_TYPE_LABELS[m.type]}</div>
|
||
<div class="m-status">${statusText}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
} else {
|
||
progressBox.style.display = "none";
|
||
}
|
||
|
||
document.querySelectorAll("[data-copy]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(btn.dataset.copy);
|
||
btn.textContent = "✓"; btn.classList.add("kopiert");
|
||
setTimeout(() => { btn.textContent = "📋"; btn.classList.remove("kopiert"); }, 1400);
|
||
} catch { /* Clipboard evtl. nicht erlaubt — kein Beinbruch */ }
|
||
});
|
||
});
|
||
|
||
const premEl = document.getElementById("detail-premiums");
|
||
if (!premiums.length) {
|
||
premEl.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet.</p>`;
|
||
} else {
|
||
premEl.innerHTML = premiums
|
||
.map(
|
||
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
|
||
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
|
||
<div class="vw-premium-info">
|
||
<strong>${escapeHtml(p.collection_name)}</strong> — Monat ${p.milestone_month}
|
||
<div class="small" style="color:var(--text-muted);">${p.tracking_number ? "Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
|
||
</div>
|
||
<div class="vw-premium-row-fields">
|
||
<select class="status-select">
|
||
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
|
||
</select>
|
||
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
|
||
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
premEl.querySelectorAll("[data-save]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const row = btn.closest("[data-premium-id]");
|
||
const redeemStatus = row.querySelector(".status-select").value;
|
||
const trackingNumber = row.querySelector(".tracking-input").value.trim();
|
||
const erfolgEl = document.getElementById("premium-erfolg");
|
||
const fehlerEl = document.getElementById("premium-fehler");
|
||
fehlerVerstecken(fehlerEl); fehlerVerstecken(erfolgEl);
|
||
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
|
||
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
|
||
});
|
||
if (!r.ok) return fehlerZeigen(fehlerEl, r.error || "Speichern fehlgeschlagen.");
|
||
fehlerZeigen(erfolgEl, "Gespeichert.");
|
||
erfolgEl.classList.add("zeige");
|
||
});
|
||
});
|
||
}
|
||
|
||
const payEl = document.getElementById("detail-payments");
|
||
payEl.innerHTML = !payments.length
|
||
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
|
||
: payments
|
||
.map((p) => {
|
||
const pStatus = p.payment_status || "completed";
|
||
return `<div class="vw-pay-row">
|
||
<span class="vw-pay-datum">${fmtDatum(p.paid_at)}</span>
|
||
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : "–"}</span>
|
||
<span class="vw-pay-status status-${pStatus}">${PAYMENT_STATUS_LABELS[pStatus] || pStatus}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
document.getElementById("zurueck-link").addEventListener("click", () => {
|
||
document.getElementById("detail-ansicht").style.display = "none";
|
||
document.getElementById("liste-ansicht").style.display = "";
|
||
});
|
||
|
||
// Nutzer-Wunsch 04.08.2026 (aktualisiert): der Zugangscode soll nur
|
||
// EINMAL PRO TAG eingegeben werden müssen, nicht bei jedem Besuch (das
|
||
// war die ursprüngliche, jetzt bewusst gelockerte Regel). admin-auth.js
|
||
// speichert die Sitzung dafür seit heute in localStorage mit eigenem
|
||
// 24-Std-Ablauf, der Server erzwingt zusätzlich dasselbe Zeitlimit
|
||
// (lib/auth.js) — läuft die Sitzung ab, verlangt pruefeSession() ganz
|
||
// automatisch wieder den Gate-Bildschirm.
|
||
pruefeSession();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|