Compare commits
@@ -18,10 +18,10 @@
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Seite nicht gefunden — DOGFATHER UNIVERSE</title>
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="site-header"></div>
|
||||
@@ -41,7 +41,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-404.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-404.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* =====================================================================
|
||||
Preis-/Anmelde-Kiste — komplett neu gestaltet (Nutzer-Feedback
|
||||
@@ -265,7 +265,12 @@
|
||||
<div class="abo-logo-glut" aria-hidden="true"></div>
|
||||
<div class="abo-logo-wz" aria-hidden="true"></div>
|
||||
<span class="eyebrow" data-i18n="ab_preis_eyebrow">DogiCrew 🩵</span>
|
||||
<div class="abo-preis-zahl">4,99 € <small data-i18n="ab_preis_pro_monat">/ Monat</small></div>
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: Preis noch NICHT zeigen, solange die
|
||||
Registrierung gesperrt ist (siehe .btn-gold-locked weiter unten) — sonst
|
||||
wäre der Preis schon öffentlich, bevor überhaupt jemand abschließen kann.
|
||||
Gleicher Chrome-Gold-Schimmer-Text wie beim gesperrten Knopf
|
||||
(.btn-gold-locked-text), hier freistehend statt in einem Knopf. -->
|
||||
<div class="abo-preis-zahl"><span class="btn-gold-locked-text" style="font-size:1em;" data-i18n="ab_kollektion_coming_soon">Coming soon…</span></div>
|
||||
<p class="small muted" style="margin-top:-.6rem;margin-bottom:1rem;">Keine gesondert ausgewiesene MwSt. gemäß Kleinunternehmerregelung (Art. 57 des luxemburgischen TVA-Gesetzes) — keine weiteren Kosten.</p>
|
||||
<ul class="abo-bedingungen">
|
||||
<li data-i18n="ab_bed1">Monatliche automatische Zahlung über PayPal</li>
|
||||
@@ -308,30 +313,65 @@
|
||||
|
||||
<!-- Zustand 2: Registrierung -->
|
||||
<div class="abo-panel" id="panel-register" hidden>
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026 (Nachpruefung): Nur der Knopf war
|
||||
gesperrt, die drei Felder ließen sich trotzdem antippen und
|
||||
beschreiben ("ich kann immer nur was rein tippen, soll garnicht
|
||||
möglich sein"). Jetzt wie das E-Mail-Feld im Login-Panel: disabled
|
||||
statt nur readonly, damit auch Tastatur-/Screenreader-Nutzung
|
||||
korrekt "nicht bedienbar" meldet. -->
|
||||
<div class="abo-field">
|
||||
<label for="reg-name" data-i18n="ab_feld_name">Name oder Anzeigename</label>
|
||||
<input type="text" id="reg-name" maxlength="60" />
|
||||
<input type="text" id="reg-name" maxlength="60" disabled />
|
||||
</div>
|
||||
<div class="abo-field">
|
||||
<label for="reg-tiktok" data-i18n="ab_feld_tiktok">TikTok-Benutzername</label>
|
||||
<input type="text" id="reg-tiktok" placeholder="@benutzername" maxlength="40" />
|
||||
<input type="text" id="reg-tiktok" placeholder="@benutzername" maxlength="40" disabled />
|
||||
</div>
|
||||
<div class="abo-field">
|
||||
<label for="reg-email" data-i18n="ab_feld_email">E-Mail-Adresse</label>
|
||||
<input type="email" id="reg-email" maxlength="120" />
|
||||
<input type="email" id="reg-email" maxlength="120" disabled />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-register" data-i18n="ab_btn_register">Registrieren</button>
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: Die DogiCrew-Registrierung ist
|
||||
technisch fertig, aber ohne Dogis echte PayPal-Business-Zugangsdaten
|
||||
kann niemand wirklich zahlen (siehe DogFather Website - Offene
|
||||
Punkte.md). Bis die stehen: Knopf bleibt sichtbar (Teaser, "soll
|
||||
alles bleiben"), aber NICHT bedienbar — natives disabled-Attribut
|
||||
(nicht nur optisch/pointer-events, damit es auch für Tastatur/
|
||||
Screenreader korrekt gesperrt ist). main.js hängt weiterhin
|
||||
unverändert einen Klick-Listener an dieselbe ID — disabled-Buttons
|
||||
feuern in allen Browsern keine Click-Events, daher hier bewusst
|
||||
KEINE JS-Änderung nötig. Eigener Gold/Schloss-Stil (.btn-gold-locked,
|
||||
siehe main.css) statt des generischen .btn-silver, der auf dieser
|
||||
Seite schon für die Teddy-Kollektion vergeben ist. -->
|
||||
<button type="button" class="btn-gold-locked" style="width:100%;margin-top:.2rem;" id="btn-register"
|
||||
disabled aria-disabled="true">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span>
|
||||
</button>
|
||||
<p class="abo-fehler" id="register-fehler"></p>
|
||||
<p class="abo-switch"><a id="switch-to-login" data-i18n="ab_switch_login">Schon Supporter? Hier einloggen</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 3: Login (E-Mail-Code anfordern) -->
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: War die einzige noch offene Lücke, nachdem
|
||||
der Registrieren-Knopf gesperrt wurde — über "Schon Supporter? Hier
|
||||
einloggen" kam man hierher und konnte trotzdem einen echten Login-Code
|
||||
anfordern, obwohl die DogiCrew noch gar nicht live ist. Jetzt genauso
|
||||
gesperrt: E-Mail-Feld disabled (nicht eintippbar, wie gewünscht), Knopf
|
||||
derselbe .btn-gold-locked-Stil wie beim Registrieren-Knopf. Betrifft NUR
|
||||
diesen öffentlichen Anmelde-Einstieg — bereits eingeloggte Supporter
|
||||
(Dogi/VanVan als Test-Accounts) merken nichts davon, ihr Zugang läuft
|
||||
weiterhin über den gespeicherten Token/supporter.html. -->
|
||||
<div class="abo-panel" id="panel-login" hidden>
|
||||
<div class="abo-field">
|
||||
<label for="login-email" data-i18n="ab_feld_email">E-Mail-Adresse</label>
|
||||
<input type="email" id="login-email" maxlength="120" />
|
||||
<input type="email" id="login-email" maxlength="120" disabled />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-login-request" data-i18n="ab_btn_login_code">Login-Code anfordern</button>
|
||||
<button type="button" class="btn-gold-locked" style="width:100%;margin-top:.2rem;" id="btn-login-request"
|
||||
disabled aria-disabled="true">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span>
|
||||
</button>
|
||||
<p class="abo-fehler" id="login-fehler"></p>
|
||||
<p class="abo-switch"><a id="switch-to-register" data-i18n="ab_switch_register">Noch kein Konto? Jetzt registrieren</a></p>
|
||||
</div>
|
||||
@@ -387,7 +427,10 @@
|
||||
<div class="container grid grid-2">
|
||||
<div class="card">
|
||||
<p data-i18n="ab_text1">Dieses Abonnement ist kein gewöhnlicher Shop und kein einfacher Kauf. Es ist für die Menschen gedacht, die Dogfather dauerhaft unterstützen und ein besonderer Teil dieser gemeinsamen Reise sein möchten.</p>
|
||||
<p data-i18n="ab_text2">Für 4,99 € im Monat erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.</p>
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: "4,99 €" hier entfernt (nur die Zahl, Rest
|
||||
des Satzes unverändert) — sonst hätte der eigentlich gesperrte Preis
|
||||
weiter oben in der Preis-Box hier im Fließtext trotzdem gestanden. -->
|
||||
<p data-i18n="ab_text2">Mit deinem monatlichen Beitrag erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.</p>
|
||||
<ul class="abo-vorteile">
|
||||
<li data-i18n="ab_vorteil1">Exklusiver Supporter-Bereich</li>
|
||||
<li data-i18n="ab_vorteil2">Kleiner Hasen-Teddy nach 6 bezahlten Monaten</li>
|
||||
@@ -411,9 +454,9 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-abonnieren.js"></script>
|
||||
<script src="assets/js/supporter.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-abonnieren.js?v=20260821n"></script>
|
||||
<script src="assets/js/supporter.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const S = window.DogiSupporter;
|
||||
@@ -662,7 +705,15 @@
|
||||
} catch {
|
||||
/* Konfiguration konnte nicht geladen werden — Google-Button/PayPal-Buttons bleiben dann aus. */
|
||||
}
|
||||
initGoogleConsent();
|
||||
/* Bugfix/Nutzer-Wunsch 20.08.2026: "Mit Google anmelden" wäre ein dritter, bisher
|
||||
übersehener Weg gewesen, trotz gesperrtem Registrieren-/Login-Knopf doch ein
|
||||
echtes Konto anzulegen bzw. sich einzuloggen (falls GOOGLE_CLIENT_ID auf dem
|
||||
Server konfiguriert ist). initGoogleConsent() daher bewusst NICHT mehr
|
||||
aufgerufen, solange die DogiCrew-Registrierung gesperrt ist — panel-social-login
|
||||
bleibt dadurch dauerhaft hidden. Einfach diesen Aufruf wieder aktivieren,
|
||||
sobald die Registrierung selbst (btn-register/btn-login-request, siehe oben)
|
||||
entsperrt wird. */
|
||||
// initGoogleConsent();
|
||||
await pruefeStatus();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="site-header"></div>
|
||||
@@ -134,6 +134,6 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Detailansicht einer Aktion oder eines Projekts von Team Dogi." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="site-header"></div>
|
||||
@@ -51,9 +51,9 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-aktionen.js"></script>
|
||||
<script src="assets/js/i18n-aktion-detail.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-aktionen.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-aktion-detail.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
@@ -121,17 +121,14 @@ header.site-header {
|
||||
braucht durch größere Schrift + mehr Abstand + Polarlicht-Padding
|
||||
spürbar mehr Platz, ohne dass der restliche Seiteninhalt breiter
|
||||
werden soll.
|
||||
Bugfix 04.08.2026: Der feste 1400px-Deckel war zu knapp — der
|
||||
tatsächliche Inhalt (Logo+Menü+Sprachschalter+DogiCrew-Knopf+
|
||||
Bewerben-Knopf) braucht bei aufgeklapptem Desktop-Menü (ab 1480px
|
||||
Breite, siehe Media Query unten) ca. 1417px und lief dadurch IMMER
|
||||
um ca. 17px über den Rahmen hinaus — auf breiten Bildschirmen ohne
|
||||
sichtbaren Schaden (genug Luft drumherum), aber bei schmaleren
|
||||
Fenstern knapp über der 1480px-Schwelle (z.B. Laptop-Fenster nicht
|
||||
maximiert, oder Browser-Zoom >100%) wurde dadurch der rechte Rand
|
||||
(der "Bewerben"-Knopf) sichtbar abgeschnitten. 1400px -> 1440px gibt
|
||||
ausreichend Puffer, ohne auf breiten Screens spürbar größer zu wirken. */
|
||||
width: min(1440px, 94vw);
|
||||
Bugfix 04.08.2026: 1400px -> 1440px angehoben (siehe damalige Analyse).
|
||||
Bugfix 20.08.2026: reichte seither wieder nicht mehr — die Nav braucht
|
||||
inzwischen (Nav-Punkt "DogiCrew" + "BALD"-Badge dazugekommen) real ca.
|
||||
1491px, der Deckel blieb bei 1440px. 1440px -> 1560px, zusammen mit der
|
||||
auf 1650px angehobenen Hamburger-Schwelle weiter unten (siehe Kommentar
|
||||
dort) — diesmal mit echtem Sicherheitsabstand statt wieder nur knapp
|
||||
genug für den aktuellen Stand. */
|
||||
width: min(1560px, 94vw);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -414,11 +411,21 @@ header.site-header {
|
||||
0% { background-position: 160% 0; }
|
||||
100% { background-position: -60% 0; }
|
||||
}
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report per Screenshot-Vergleich: Kategorie-
|
||||
Menüs wie "Medien" sind mittig unter dem Knopf, der Sprachschalter aber
|
||||
rechtsbündig) — war bewusst rechtsbündig, seit der Sprachschalter aber
|
||||
nicht mehr ganz am rechten Rand sitzt (DogiCrew-/Bewerben-Knopf daneben),
|
||||
ist genug Platz für dieselbe zentrierte Ausrichtung wie überall sonst.
|
||||
left/transform hier deshalb NICHT mehr überschrieben (erbt jetzt
|
||||
left:50%/translateX(-50%) von der allgemeinen .nav-dropdown-Regel,
|
||||
genau wie der bereits zentrierte Verbindungs-Pfeil (::before) es die
|
||||
ganze Zeit schon war — vorher zeigte der Pfeil mittig, während die Box
|
||||
selbst daneben saß). Eigene Animation bleibt bestehen (siehe Kommentar
|
||||
der allgemeinen .nav-dropdown-Animation), jetzt aber ebenfalls mit
|
||||
translateX(-50%) in jedem Frame, sonst würde sie die Zentrierung wieder
|
||||
aufheben. */
|
||||
.sprach-schalter .nav-dropdown {
|
||||
min-width: 190px;
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
padding: .4rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(210, 216, 224, .55);
|
||||
@@ -427,12 +434,11 @@ header.site-header {
|
||||
0 14px 34px rgba(0, 0, 0, .6),
|
||||
0 0 16px rgba(220, 226, 235, .35),
|
||||
0 0 34px rgba(190, 200, 215, .18);
|
||||
transform-origin: top right;
|
||||
animation: sprach-dropdown-in .18s ease both, sprach-silber-puls 3.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sprach-dropdown-in {
|
||||
from { opacity: 0; transform: translateY(-6px) scale(.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(.96); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
||||
}
|
||||
/* Silberner Polarlicht-Schein um den Dropdown-Rand — bewusst neutral
|
||||
silber statt farbig, damit sich der schwarze Kasten von der bunten
|
||||
@@ -613,6 +619,34 @@ header.site-header {
|
||||
0 0 22px rgba(143, 217, 234, .35),
|
||||
0 0 22px rgba(139, 92, 246, .3);
|
||||
}
|
||||
/* "Bald"-Badge auf dem Abo-Knopf, solange die Registrierung auf
|
||||
abonnieren.html gesperrt ist (20.08.2026, siehe .btn-gold-locked weiter
|
||||
unten + main.js renderAboButton/soonBadge). Sitzt als zweites Kind IN
|
||||
der Pille (nicht als eigenes Nav-Element), bleibt dadurch Teil desselben
|
||||
Klicks — der Knopf führt bewusst weiterhin ganz normal zu abonnieren.html,
|
||||
nur die Registrierung selbst dort ist gesperrt. Bewusst neutral-silbern
|
||||
(Wiederverwendung von .btn-silver-text/-spark, nur kleiner skaliert)
|
||||
statt Gold, damit es nicht mit der babyblau/lila-Aurora dieser Pille
|
||||
konkurriert — reine Status-Markierung, kein zweiter Marken-Akzent. */
|
||||
.nav-cta-soon {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .22rem;
|
||||
margin-left: .5rem;
|
||||
padding: .15rem .5rem;
|
||||
border-radius: 999px;
|
||||
font-size: .64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, .07);
|
||||
border: 1px solid rgba(255, 255, 255, .3);
|
||||
box-shadow: 0 0 10px rgba(230, 235, 242, .3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-cta-soon .btn-silver-spark { font-size: .85em; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-cta::before, .nav-cta::after { animation: none; }
|
||||
}
|
||||
@@ -937,6 +971,108 @@ header.site-header {
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
"Gesperrter Tresor"-Knopf für den DogiCrew-Registrieren-Knopf
|
||||
(abonnieren.html, 20.08.2026, Nutzer-Wunsch: "richtig krank übertrieben
|
||||
geiles... weil es wird ja das geilste Bonus und überhaupt so von mir").
|
||||
Bewusst KEINE Wiederverwendung von .btn-silver — der ist neutral-grau und
|
||||
auf DIESER Seite bereits für die Teddy-Kollektion (Nebensache) vergeben.
|
||||
Chrome-Text-Clip-Schimmer + Schimmer-Sweep + Halo-Puls wie .btn-silver/
|
||||
.btn-legendary, aber in den ECHTEN Gold-Tönen dieser Seite
|
||||
(#ffe9a8/#d4af37, siehe .abo-panel .btn) und mit einem pulsierenden
|
||||
Schloss statt Sternen, damit die Haupt-CTA sichtbar "das Besondere,
|
||||
noch verschlossene" bleibt statt nur "irgendwann verfügbar".
|
||||
WICHTIG: Für den Rand bewusst NICHT die rotierende Conic-Gradient-Maske
|
||||
von .btn-silver/.btn-legendary kopiert — dieser Knopf läuft über
|
||||
`width:100%` auf voller Kartenbreite (~440px) bei nur ~62px Höhe, also
|
||||
ein sehr langgestrecktes Rechteck. Genau für diesen Fall ist die
|
||||
dokumentierte Lektion in Projektregeln.md Punkt 14 gedacht (03.08.2026,
|
||||
"TikTok-Button-Bug"): ein rotierendes maskiertes Pseudo-Element verzieht
|
||||
sich auf länglichen Formen zu einer krummen Schlaufe, weil ein Rechteck
|
||||
beim Rotieren nicht rotationssymmetrisch ist — live mit Playwright-
|
||||
Screenshots über mehrere Frames nachgewiesen (die Schlaufe ist der
|
||||
rotierende Rand bei verschiedenen Winkeln). Stattdessen derselbe sichere
|
||||
Zwei-Layer-Background-Trick wie bei .abo-google-frame weiter oben in
|
||||
dieser Datei (Füllung padding-box + Verlauf border-box, Bewegung nur
|
||||
über background-position) — bleibt geometrisch exakt an der Kontur,
|
||||
unabhängig vom Seitenverhältnis. Reine Deko/Statusanzeige — echte
|
||||
Deaktivierung läuft über das native disabled-Attribut am <button>
|
||||
(siehe abonnieren.html), nicht über pointer-events, damit auch
|
||||
Tastatur-/Screenreader-Nutzung korrekt "nicht bedienbar" meldet. Bewusst
|
||||
KEIN :hover-Zustand (keine Lift-Animation wie bei .btn-silver/
|
||||
.btn-legendary) — soll nicht so wirken, als wäre er doch klickbar. */
|
||||
.btn-gold-locked {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .65rem;
|
||||
padding: 1rem 1.4rem;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
font-size: 1.04rem;
|
||||
letter-spacing: .02em;
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(160deg, #1c1710, #0b0e14 55%, #211a0d) padding-box,
|
||||
linear-gradient(120deg, #d4af37, #ffe9a8, #b8912b, #fff2c9, #d4af37) border-box;
|
||||
background-size: 100% 100%, 260% 100%;
|
||||
cursor: not-allowed;
|
||||
animation: btn-gold-locked-border-shine 6s ease-in-out infinite, btn-gold-locked-halo 3s ease-in-out infinite;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, .45), 0 0 0 1px rgba(212, 175, 55, .08) inset;
|
||||
}
|
||||
.btn-gold-locked-lock {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-block;
|
||||
font-size: 1.05em;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 214, 102, .85));
|
||||
animation: btn-gold-locked-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.btn-gold-locked-text {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: linear-gradient(100deg, #ffe9a8 0%, #ffffff 24%, #d4af37 42%, #ffe9a8 60%, #ffffff 78%, #d4af37 100%);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6));
|
||||
animation: btn-silver-text-shine 3.6s linear infinite; /* bestehendes Keyframe wiederverwendet */
|
||||
}
|
||||
.btn-gold-locked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(115deg, transparent 32%, rgba(255, 233, 168, .5) 49%, transparent 66%);
|
||||
background-size: 240% 100%;
|
||||
mix-blend-mode: overlay;
|
||||
animation: nav-cta-shimmer 4s linear infinite; /* bestehendes Keyframe wiederverwendet */
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes btn-gold-locked-border-shine {
|
||||
0%, 100% { background-position: 0 0, 0% 50%; }
|
||||
50% { background-position: 0 0, 100% 50%; }
|
||||
}
|
||||
@keyframes btn-gold-locked-pulse {
|
||||
0%, 100% { opacity: .7; transform: scale(.92) rotate(-4deg); }
|
||||
50% { opacity: 1; transform: scale(1.12) rotate(4deg); }
|
||||
}
|
||||
@keyframes btn-gold-locked-halo {
|
||||
0%, 100% { box-shadow: 0 8px 22px rgba(0, 0, 0, .45), 0 0 16px rgba(212, 175, 55, .22); }
|
||||
50% { box-shadow: 0 8px 22px rgba(0, 0, 0, .45), 0 0 32px rgba(255, 214, 102, .45); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-gold-locked, .btn-gold-locked::after,
|
||||
.btn-gold-locked-text, .btn-gold-locked-lock {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
.btn-holo — zweiter "Mega"-Button-Stil, bewusst MECHANISCH anders als
|
||||
.btn-legendary/.btn-silver/.btn-tiktok (die alle nach demselben Muster
|
||||
@@ -1308,14 +1444,21 @@ header.site-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Schwelle von ursprünglich 880px auf 1480px angehoben: nach mehreren
|
||||
Vergrößerungsrunden (Logo/Nav-Schrift/Sprachschalter/Bewerben-Button,
|
||||
mehr Abstand zwischen den Nav-Punkten, größerer eigener Nav-Container,
|
||||
Polarlicht-Padding je Kategorie) braucht die Desktop-Nav deutlich mehr
|
||||
Platz als früher. Live bei mehreren Breiten nachgeprüft (siehe Commits
|
||||
vom 02.08.2026) — Schwelle hier bewusst großzügig, damit nie wieder
|
||||
Elemente sichtbar aus der Leiste verschwinden. */
|
||||
@media (max-width: 1480px) {
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report per Screenshot: "DogiCrew"-Knopf/Badge
|
||||
rechts abgeschnitten): die Schwelle war schon zweimal angehoben (880 ->
|
||||
1480px, dazu der .nav-bar-Deckel 1400 -> 1440px), reichte aber seither
|
||||
NICHT mehr aus — seit den neu dazugekommenen Nav-Punkten "DogiCrew" +
|
||||
"BALD"-Badge braucht die aufgeklappte Desktop-Nav real ca. 1491px
|
||||
Breite (per Playwright über mehrere Breiten nachgemessen), der Deckel
|
||||
blieb aber bei 1440px hart begrenzt. Ergebnis: bei JEDER Fensterbreite
|
||||
ab ca. 1500px (nicht nur in einer schmalen Übergangszone) lief die Nav
|
||||
permanent ca. 50px über den Rand hinaus. Diesmal mit echtem Sicherheits-
|
||||
abstand behoben statt wieder nur knapp: Schwelle auf 1650px, Deckel auf
|
||||
1560px — bei exakt 1650px ergibt min(1560px, 94vw) ≈ 1551px, spürbar
|
||||
mehr als die gemessenen ~1491px nötigen Breite. Bei jeder Fensterbreite
|
||||
NACH der Schwelle reicht 94vw automatisch weiter aus (nur ganz nah an
|
||||
der Schwelle war die Lücke kritisch). */
|
||||
@media (max-width: 1650px) {
|
||||
.nav-toggle { display: block; }
|
||||
.nav-links {
|
||||
position: fixed;
|
||||
@@ -1336,6 +1479,22 @@ header.site-header {
|
||||
.nav-dropdown::before { display: none; }
|
||||
.nav-group.open .nav-dropdown { display: flex; }
|
||||
.nav-cta { align-self: flex-start; margin: 1rem 0 .6rem; }
|
||||
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report: "auf dem Handy passt nicht immer alles").
|
||||
Per echter Mobil-Emulation (iPhone 15 Pro + Pixel 8, Screenshots) nachgestellt:
|
||||
Die schwebenden Eck-Knöpfe (Musik unten rechts, Vorschau-Umschalter unten links
|
||||
für Dogi/VanVan — siehe .music-widget/.vorschau-widget weiter unten) sind
|
||||
absichtlich IMMER sichtbar (position:fixed, direkte <body>-Kinder, eigener
|
||||
Stacking-Kontext, unabhängig vom Menü). Bei aufgeklapptem Mobil-Menü liegen sie
|
||||
dadurch ÜBER den untersten Menüpunkten (Sprachschalter + "Bewerben"-Knopf) und
|
||||
verdecken/blockieren sie sichtbar. :has() statt einer weiteren main.js-Änderung,
|
||||
weil beide Widgets keinen gemeinsamen Elternknoten mit .nav-links haben (beide
|
||||
hängen direkt unter <body>, .nav-links steckt im Header) — reiner CSS-Fix bleibt
|
||||
an der Stelle, an der auch die restliche Menü-Overlay-Logik dieses Blocks steht. */
|
||||
body:has(.nav-links.open) .music-widget,
|
||||
body:has(.nav-links.open) .vorschau-widget {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Buttons ----------
|
||||
@@ -1444,6 +1603,185 @@ header.site-header {
|
||||
}
|
||||
@media (max-width: 760px) { .hero-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* "Event des Jahres" (20.08.2026) — volle Breite wie .hero-grid darüber (eigener Block,
|
||||
kein Grid-Kind), zeigt bewusst IMMER genau zwei Events nebeneinander. Gleiche
|
||||
Karten-/Tag-Optik wie die 3 Welt-Kacheln, damit es wie ein natürlicher Abschluss des
|
||||
Grids wirkt statt wie ein fremdes Element. */
|
||||
.jahres-events {
|
||||
margin-top: 1.2rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.6rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.jahres-events-head { margin-bottom: 1.2rem; }
|
||||
.jahres-events-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.2rem;
|
||||
}
|
||||
@media (max-width: 760px) { .jahres-events-grid { grid-template-columns: 1fr; } }
|
||||
/* Nutzer-Wunsch 20.08.2026: "die kacheln sollen auch bissl spezieller sein" —
|
||||
goldener Trophäen-Akzent statt der neutralen Standard-Kartenoptik (eigener,
|
||||
gedeckter Farbton, kein grelles Gold — augenschonend), plus ein dezenter
|
||||
wandernder Lichtschimmer oben (".jahres-event-shine"), der die Kachel als
|
||||
etwas Besonderes markiert statt als normale Karte. Jetzt ein <button>
|
||||
(öffnet ein Detail-Fenster) statt eines <a> (navigierte sofort weg) —
|
||||
deshalb hier zusätzlich ein vollständiger Button-Reset. */
|
||||
/* [hidden] explizit VOR der Klassenregel + als eigene Regel danach: ein
|
||||
simples "display:block" in .jahres-event-card hat dieselbe Spezifität
|
||||
wie die eingebaute [hidden]-UA-Regel des Browsers und würde sie sonst im
|
||||
Zweifel überschreiben (identischer Bug wie schon bei .jahres-event-bild
|
||||
weiter unten, hier per Playwright-Screenshot erneut gefunden — eine
|
||||
"leere" Kachel blieb trotz hidden-Attribut sichtbar). */
|
||||
.jahres-event-card[hidden] { display: none; }
|
||||
.jahres-event-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
padding: 1.2rem 1.4rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
border: 1px solid rgba(212, 175, 55, .35);
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, rgba(212, 175, 55, .08), transparent 55%),
|
||||
rgba(255, 255, 255, .02);
|
||||
transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.jahres-event-card:hover, .jahres-event-card:focus-visible {
|
||||
border-color: rgba(212, 175, 55, .7);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, .35), 0 0 22px rgba(212, 175, 55, .18);
|
||||
}
|
||||
.jahres-event-shine {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 6%; right: 6%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 214, 102, .9), transparent);
|
||||
animation: jahres-event-goldglanz 4.5s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes jahres-event-goldglanz { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .jahres-event-shine { animation: none; opacity: .6; } }
|
||||
.jahres-event-datum {
|
||||
display: block;
|
||||
color: #e8c465;
|
||||
font-weight: 700;
|
||||
font-size: .82rem;
|
||||
letter-spacing: .02em;
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
.jahres-event-card h3 { margin: 0 0 .4rem; font-size: 1.15rem; }
|
||||
.jahres-event-card p { margin: 0 0 .6rem; color: var(--text-muted); font-size: .93rem; line-height: 1.55; }
|
||||
.jahres-event-mehr { color: #e8c465; font-weight: 600; font-size: .88rem; }
|
||||
/* Genau EIN aktives Event: zentrierte, etwas größere Einzelkachel statt
|
||||
halbleerem Zwei-Spalten-Raster (Nutzer-Wunsch: "spezieller"). */
|
||||
.jahres-events-grid-einzeln {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.jahres-events-grid-einzeln .jahres-event-card { padding: 1.6rem 1.8rem; }
|
||||
.jahres-events-grid-einzeln .jahres-event-card h3 { font-size: 1.3rem; }
|
||||
/* Optionales Admin-Bild pro Event (20.08.2026, "Event des Jahres" jetzt in
|
||||
der Verwaltung pflegbar) — bleibt per [hidden] unsichtbar, solange kein
|
||||
Bild gesetzt ist, damit die textbasierten Standard-Karten unverändert
|
||||
aussehen. Bugfix (per Playwright-Test gefunden): "display:block" allein
|
||||
hätte dieselbe Spezifität wie die eingebaute [hidden]-Regel des Browsers
|
||||
und würde sie im Zweifel überschreiben (leerer Bild-Rahmen wäre auch OHNE
|
||||
gesetztes Bild sichtbar gewesen) — deshalb hier zusätzlich explizit
|
||||
:not([hidden]) statt nur der Klasse allein. */
|
||||
.jahres-event-bild[hidden] { display: none; }
|
||||
.jahres-event-bild:not([hidden]) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
margin-bottom: .9rem;
|
||||
}
|
||||
|
||||
/* ---------- "Event des Jahres" — Detail-Fenster (20.08.2026) ----------
|
||||
Nutzer-Wunsch: "wen ich drauf drücke dass dein ein kleines fenster
|
||||
aufgeht wo das bild bissl größer ist mit einem größeren text und
|
||||
beschreibung, und mit einem link ... bereite das richtig geil krank
|
||||
vor." Augenschonend gehalten (siehe Projektregel): kein grelles Weiß,
|
||||
sanftes Ein-/Ausblenden statt hartem Umschalten, respektiert
|
||||
prefers-reduced-motion. */
|
||||
.jahres-event-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4vh 5vw;
|
||||
background: rgba(5, 7, 11, .78);
|
||||
backdrop-filter: blur(6px);
|
||||
opacity: 0;
|
||||
transition: opacity .22s ease;
|
||||
}
|
||||
.jahres-event-modal-overlay.offen { opacity: 1; }
|
||||
.jahres-event-modal-overlay[hidden] { display: none; }
|
||||
body.modal-offen { overflow: hidden; }
|
||||
.jahres-event-modal {
|
||||
position: relative;
|
||||
width: min(640px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(212, 175, 55, .4);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, .5), 0 0 40px rgba(212, 175, 55, .12);
|
||||
transform: scale(.94) translateY(10px);
|
||||
transition: transform .25s ease;
|
||||
}
|
||||
.jahres-event-modal-overlay.offen .jahres-event-modal { transform: scale(1) translateY(0); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jahres-event-modal-overlay, .jahres-event-modal { transition: none; }
|
||||
}
|
||||
.jahres-event-modal-close {
|
||||
position: absolute;
|
||||
top: .9rem; right: .9rem;
|
||||
z-index: 2;
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, .25);
|
||||
background: rgba(5, 7, 11, .7);
|
||||
color: var(--text);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, transform .15s ease;
|
||||
}
|
||||
.jahres-event-modal-close:hover { border-color: rgba(212, 175, 55, .7); transform: scale(1.06); }
|
||||
.jahres-event-modal-bild {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 46vh;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
.jahres-event-modal-bild[hidden] { display: none; }
|
||||
.jahres-event-modal-body { padding: 1.8rem 2rem 2.2rem; }
|
||||
.jahres-event-modal-datum {
|
||||
display: block;
|
||||
color: #e8c465;
|
||||
font-weight: 700;
|
||||
font-size: .92rem;
|
||||
letter-spacing: .03em;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.jahres-event-modal-body h2 { font-size: 1.7rem; margin: 0 0 .8rem; }
|
||||
.jahres-event-modal-body p { font-size: 1.08rem; line-height: 1.7; color: var(--text-muted); margin: 0 0 1.4rem; }
|
||||
.jahres-event-modal-link[hidden] { display: none; }
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
@@ -1645,6 +1983,50 @@ header.site-header {
|
||||
box-shadow: 0 0 60px 6px rgba(143,217,234,.18);
|
||||
opacity: .85;
|
||||
}
|
||||
/* "Diese Seite wächst noch"-Hinweis (20.08.2026, Nutzer-Wunsch) — bewusst
|
||||
einladend statt wie eine Fehler-/Baustellen-Meldung, im selben Babyblau
|
||||
wie der Rest des Wachstums-Baum-Themas dieser Seite (.zeit-baum-kappe/
|
||||
-wurzel). Sitzt VOR dem Baum, damit Besucher sofort verstehen: hier
|
||||
entsteht noch etwas, ohne dass es wie ein Fehler wirkt. */
|
||||
.zeit-baustelle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 2.6rem;
|
||||
padding: 1.1rem 1.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(143, 217, 234, .06);
|
||||
border: 1.5px solid rgba(143, 217, 234, .35);
|
||||
box-shadow: 0 0 24px rgba(143, 217, 234, .12);
|
||||
}
|
||||
.zeit-baustelle-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(143, 217, 234, .55));
|
||||
animation: zeit-baustelle-wiegen 3.5s ease-in-out infinite;
|
||||
}
|
||||
.zeit-baustelle strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: .3rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.zeit-baustelle p {
|
||||
margin: 0;
|
||||
font-size: .92rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
@keyframes zeit-baustelle-wiegen {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(5deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zeit-baustelle-icon { animation: none; }
|
||||
}
|
||||
|
||||
.zeit-baum-kappe {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1754,6 +2136,16 @@ header.site-header {
|
||||
.zeit-ast.zeit-final .zeit-karte { width: calc(100% - 4rem); max-width: 640px; text-align: center; }
|
||||
.zeit-ast.zeit-final { justify-content: center !important; }
|
||||
.zeit-ast.zeit-final .zeit-karte::before { display: none; }
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report: "wieso dieser Punkt da in der Mitte"):
|
||||
.zeit-node ist bei allen normalen Karten position:absolute; left:50%;
|
||||
top:50% relativ zu .zeit-ast — das sitzt korrekt in der Baum-Mitte,
|
||||
WEIL die Karte dort nur die halbe Breite einnimmt. Die Finale-Karte
|
||||
(.zeit-final) wird aber auf fast volle Breite gestreckt und zentriert
|
||||
(siehe .zeit-karte-Regel oben) — der Knoten landete dadurch mitten im
|
||||
Text statt am Rand. Die Verbindungslinie (::before, direkt drüber)
|
||||
wurde dafür schon 2025 ausgeblendet, der Knoten selbst wurde dabei
|
||||
übersehen. */
|
||||
.zeit-ast.zeit-final .zeit-node { display: none; }
|
||||
.zeit-zitat {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
@@ -1968,6 +2360,51 @@ footer.site-footer {
|
||||
(z.B. dem DogFather-Manager-Artwork) nicht flach wirken. */
|
||||
filter: saturate(1.26) contrast(1.15) brightness(1.03);
|
||||
}
|
||||
/* Sehr hohe Hochkant-Bilder (z.B. streamer-mascot.jpg mit 900x2812 px) würden
|
||||
mit aspect-ratio:auto + height:auto in voller natürlicher Höhe rendern und die
|
||||
danebenstehende Textspalte komplett erschlagen (Nutzer-Report 20.08.2026:
|
||||
"der soll bissl kleiner sein und nicht so riesig"). Ein zu knapper max-height-
|
||||
Deckel (erster Versuch: 460px) machte es dann aber zu klein und ließ eine
|
||||
sichtbare Lücke unter dem Bild, weil die Textspalte daneben normalerweise
|
||||
deutlich mehr Platz braucht (Nutzer-Report 21.08.2026: "die größe vom bild
|
||||
vom husky soll dem text rechts nebendran angepasst werden").
|
||||
Ausprobiert und wieder verworfen: die Höhe per CSS Grid align-items:stretch +
|
||||
height:100% exakt an die Textspalte zu koppeln. Funktioniert bei Grid NICHT
|
||||
robust, weil ein 'auto' sizing Track seine Höhe am MAX-CONTENT-Beitrag beider
|
||||
Felder ausrichtet -- bei einem derart extremen Hochkantbild (Seitenverhältnis
|
||||
~1:3.1) zieht das Bild die ganze Zeile (und damit auch die Textspalte) auf
|
||||
seine eigene, riesige natürliche Höhe hoch, egal was man an min-height
|
||||
probiert (min-height wirkt nur auf den Mindest-, nicht den Wachstums-Beitrag).
|
||||
Robuster Kompromiss stattdessen: ein großzügiger fester max-height-Wert
|
||||
(720px statt 460px), der sich an der TYPISCHEN Höhe der Textspalte in diesem
|
||||
Abschnitt orientiert (auf normalen Desktop-Breiten ~715-850px hoch) -- das
|
||||
Bild wirkt dadurch angemessen groß und auf Augenhöhe mit dem Text, ohne die
|
||||
fragile/rückkoppelnde Grid-Stretch-Mechanik. width:fit-content lässt den
|
||||
Rahmen dabei eng am Bild sitzen, statt breite gestreifte Hintergrund-Balken
|
||||
danebenzulassen.
|
||||
WICHTIG: muss NACH ".collage-photo img" stehen — beide Regeln haben dieselbe
|
||||
Spezifität (eine Klasse + ein Element), die spätere gewinnt. Stünde dieser
|
||||
Block davor, würde width:100%/height:100% oben die Deckelung wieder aufheben. */
|
||||
.collage-photo-kompakt {
|
||||
aspect-ratio: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.collage-photo-kompakt img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 720px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
/* Auf Mobilgeräten stehen die Grid-Spalten untereinander -- dort ist die
|
||||
Textspalte durch die volle Breite viel kürzer, ein 720px hohes Bild wäre
|
||||
unangemessen groß für einen schmalen Handy-Screen. Deckel daher kleiner. */
|
||||
.collage-photo-kompakt img { max-height: 420px; }
|
||||
}
|
||||
/* Cineastischer "Poster-Grade" für JEDES Präsentationsfoto auf der Seite:
|
||||
zwei Farbschein-Blooms in der jeweiligen Rahmenfarbe (oben-links /
|
||||
unten-rechts, wie ein Studio-Rimlight — nutzt dieselbe --frame-Variable
|
||||
@@ -2146,7 +2583,7 @@ footer.site-footer {
|
||||
Cover, die jeden Rand auffüllt, den "contain" freilässt — dadurch
|
||||
wirkt es trotzdem randlos, obwohl die scharfen Pixel garantiert
|
||||
nie beschnitten werden. */
|
||||
background-image: url("../img/universe-trio.jpg"), url("../img/universe-trio-blur.jpg");
|
||||
background-image: url("../img/universe-trio.jpg?v=20260820h"), url("../img/universe-trio-blur.jpg?v=20260820h");
|
||||
background-size: contain, cover;
|
||||
background-position: center, center;
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
@@ -3188,3 +3625,342 @@ body.start-seite .music-toggle:focus-visible {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.todo-note strong { color: var(--accent-2); }
|
||||
|
||||
/* ===================================================================
|
||||
Streamplan — "Live-Radar" (20.08.2026, Nutzer-Wunsch: "soll viel
|
||||
geiler und spezieller sein, überrasch mich"). Ein 24-Stunden-Ziffernblatt
|
||||
als SVG: der feste 20-Uhr-Termin leuchtet dauerhaft als Fixpunkt, ein
|
||||
"Jetzt"-Zeiger bewegt sich in Echtzeit mit der tatsächlichen Uhrzeit,
|
||||
ein sanft rotierender Lichtschein sorgt für Atmosphäre. Schaltet live
|
||||
auf Rot/Puls um, sobald DogFather laut /live-status wirklich sendet —
|
||||
sonst zählt ein echter Sekunden-Countdown zum nächsten 20-Uhr-Termin
|
||||
runter. Bewusst augenschonend: gedeckte Farben, langsame, sanfte
|
||||
Animation, kompletter Stillstand bei prefers-reduced-motion. ================================================================= */
|
||||
/* Reihe für 1-2 Uhren nebeneinander (20.08.2026: zweite Uhr für ein
|
||||
"spezielles Live-Event", siehe assets/js/streamplan.js) — bricht auf
|
||||
schmalen Bildschirmen automatisch untereinander um. */
|
||||
.stream-radar-reihe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 2.4rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
/* .stream-radar-wrap setzt selbst "display", das schlägt bei gleicher
|
||||
Spezifität die eingebaute [hidden]-Regel des Browsers (identisches,
|
||||
inzwischen mehrfach gefundenes Muster auf dieser Seite) — deshalb hier
|
||||
explizit VOR der Klassenregel. */
|
||||
.stream-radar-wrap[hidden] { display: none; }
|
||||
.stream-radar-wrap { display: flex; flex-direction: column; align-items: center; margin: 0 auto; }
|
||||
.stream-radar {
|
||||
position: relative;
|
||||
width: min(300px, 70vw);
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.stream-radar-sweep {
|
||||
position: absolute;
|
||||
inset: -6%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg,
|
||||
transparent 0deg, rgba(143, 217, 234, .28) 40deg, transparent 90deg,
|
||||
transparent 200deg, rgba(232, 196, 101, .22) 250deg, transparent 300deg,
|
||||
transparent 320deg, rgba(139, 92, 246, .24) 350deg, transparent 360deg);
|
||||
animation: stream-radar-rotate 16s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Farbvariante fürs spezielle Live-Event (Nutzer-Wunsch "bissl Farbe") —
|
||||
Magenta/Violett/Gold statt Cyan/Violett/Gold, damit die zweite Uhr auch
|
||||
auf den ersten Blick als "etwas Besonderes" erkennbar ist. */
|
||||
.stream-radar.event-spezial .stream-radar-sweep {
|
||||
background: conic-gradient(from 0deg,
|
||||
transparent 0deg, rgba(255, 92, 180, .26) 40deg, transparent 90deg,
|
||||
transparent 200deg, rgba(232, 196, 101, .22) 250deg, transparent 300deg,
|
||||
transparent 320deg, rgba(139, 92, 246, .28) 350deg, transparent 360deg);
|
||||
}
|
||||
@keyframes stream-radar-rotate { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .stream-radar-sweep { animation: none; } }
|
||||
.stream-radar-dial { position: relative; width: 100%; height: 100%; display: block; }
|
||||
/* Bunter Verlauf statt reinem Grau-Ton auf dem äußeren Ring (Nutzer-Wunsch
|
||||
"bissl Farbe") — Farbverlauf steckt als <linearGradient> direkt im SVG
|
||||
jeder Uhr (siehe streamplan.html), hier nur die Basiswerte/Deckkraft. */
|
||||
.radar-ring-outer { fill: none; stroke-width: 2; opacity: .8; }
|
||||
.radar-ring-inner { fill: rgba(255, 255, 255, .02); stroke: rgba(255, 255, 255, .07); stroke-width: 1; }
|
||||
.radar-tick { stroke: rgba(255, 255, 255, .22); stroke-width: 1.5; }
|
||||
.radar-tick-major { stroke: rgba(255, 255, 255, .5); stroke-width: 2.2; }
|
||||
.radar-tick-label { fill: var(--text-muted); font-size: 11px; font-family: var(--font-body, inherit); }
|
||||
.radar-fixmarker-dot {
|
||||
fill: #e8c465;
|
||||
filter: drop-shadow(0 0 6px rgba(232, 196, 101, .9)) drop-shadow(0 0 14px rgba(232, 196, 101, .5));
|
||||
animation: stream-radar-fixpuls 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes stream-radar-fixpuls { 0%, 100% { opacity: .75; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .radar-fixmarker-dot { animation: none; opacity: .9; } }
|
||||
.radar-now-hand {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
transition: x2 .6s ease, y2 .6s ease;
|
||||
}
|
||||
.radar-now-dot {
|
||||
fill: var(--accent);
|
||||
filter: drop-shadow(0 0 6px var(--accent));
|
||||
transition: cx .6s ease, cy .6s ease;
|
||||
}
|
||||
.stream-radar.ist-live .radar-now-hand,
|
||||
.stream-radar.ist-live .radar-now-dot { stroke: #ff5c72; fill: #ff5c72; filter: drop-shadow(0 0 8px rgba(255, 92, 114, .8)); }
|
||||
.radar-center-dot { fill: var(--text); opacity: .8; }
|
||||
.stream-radar-center {
|
||||
position: absolute;
|
||||
inset: 24%;
|
||||
border-radius: 50%;
|
||||
background: rgba(8, 10, 14, .82);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: .5rem;
|
||||
gap: .25rem;
|
||||
}
|
||||
.stream-radar-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-bottom: .2rem;
|
||||
}
|
||||
.stream-radar.ist-live .stream-radar-dot { background: #ff5c72; box-shadow: 0 0 10px rgba(255, 92, 114, .8); animation: stream-radar-live-puls 1.4s ease-in-out infinite; }
|
||||
@keyframes stream-radar-live-puls { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.35); opacity: .75; } }
|
||||
@media (prefers-reduced-motion: reduce) { .stream-radar.ist-live .stream-radar-dot { animation: none; } }
|
||||
.stream-radar-status { font-size: 1rem; font-weight: 800; color: var(--text); line-height: 1.25; }
|
||||
.stream-radar-countdown {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .02em;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stream-radar.ist-live .stream-radar-countdown { color: #ff5c72; }
|
||||
.stream-radar-sub { font-size: .74rem; color: var(--text-muted); }
|
||||
/* Farbvariante fürs spezielle Live-Event: der Fixpunkt ist hier nicht der
|
||||
tägliche 20-Uhr-Termin, sondern der eigene Zieltermin -- Magenta statt
|
||||
Gold, damit man beide Uhren auf einen Blick unterscheidet. */
|
||||
.stream-radar.event-spezial .radar-fixmarker-dot {
|
||||
fill: #ff5cb8;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 92, 184, .9)) drop-shadow(0 0 14px rgba(255, 92, 184, .5));
|
||||
}
|
||||
.stream-radar.event-spezial:not(.ist-live) .radar-now-hand,
|
||||
.stream-radar.event-spezial:not(.ist-live) .radar-now-dot { stroke: #c9a6ff; fill: #c9a6ff; filter: drop-shadow(0 0 5px rgba(201, 166, 255, .8)); }
|
||||
.stream-radar.event-spezial:not(.ist-live) .stream-radar-countdown { color: #ff5cb8; }
|
||||
.stream-radar-titel { font-size: .95rem; font-weight: 800; color: #ff9ed4; margin-bottom: .3rem; }
|
||||
/* .btn setzt selbst "display", das schlägt bei gleicher Spezifität die
|
||||
eingebaute [hidden]-Regel des Browsers (identisches Muster wie bei
|
||||
.jahres-event-card weiter oben) — deshalb hier explizit. */
|
||||
.stream-radar-watch-btn[hidden] { display: none; }
|
||||
.stream-radar-watch-btn { margin-top: 1.1rem; }
|
||||
.stream-radar-hinweis { margin-top: 1rem; opacity: .8; }
|
||||
|
||||
/* ===================================================================
|
||||
"Stimmen" — eigene Nachricht einreichen (20.08.2026, Nutzer-Wunsch:
|
||||
"eine richtig geile kachel ... das wird mega persoenlich zu den fans,
|
||||
bitte wirklich krass geil speziell"). Warmer Babyblau/Rosé/Lila-
|
||||
Farbverlauf statt der neutralen Standardkarte, wandernder Lichtschein
|
||||
am Rand (dieselbe Zwei-Ebenen-Technik wie schon an mehreren Stellen
|
||||
dieses Projekts, siehe .supp-card::before) — bewusst augenschonend
|
||||
(gedeckte Farben, sanfte Bewegung, respektiert reduzierte Bewegung). ================================================================= */
|
||||
/* Nutzer-Wunsch 20.08.2026 (2. Runde): "die kachel soll noch spezieller und
|
||||
geiler aussehen". Deutlich aufgewertet gegenüber der ersten Fassung:
|
||||
wandernder Farbverlauf-Rahmen rundherum (Zwei-Ebenen-Technik mit
|
||||
background-position statt eines rotierenden Rings — siehe dokumentierte
|
||||
Projekt-Lektion: rotierende Masken verzerren auf eckigen Flächen),
|
||||
schwebende Herz-Partikel im Hintergrund, Medaillon statt nacktem Emoji,
|
||||
Farbverlauf-Überschrift und Eingabefelder, die beim Tippen aufleuchten.
|
||||
Bleibt trotzdem augenschonend: gedeckte Töne, langsame Bewegungen,
|
||||
vollständiger Stillstand bei prefers-reduced-motion. */
|
||||
.stimmen-einreichen-kachel {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
max-width: 580px;
|
||||
margin: 0 auto;
|
||||
padding: 2.4rem 2.4rem 2.6rem;
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
background:
|
||||
radial-gradient(ellipse at 12% 0%, rgba(255, 154, 208, .13), transparent 58%),
|
||||
radial-gradient(ellipse at 88% 100%, rgba(143, 217, 234, .13), transparent 58%),
|
||||
linear-gradient(180deg, rgba(24, 34, 51, .96), rgba(14, 20, 32, .98));
|
||||
box-shadow: 0 30px 70px rgba(0, 0, 0, .45), 0 0 50px rgba(143, 217, 234, .06);
|
||||
}
|
||||
/* Farbverlauf-Rahmen: zwei Ebenen, nur die Hintergrundposition wandert. */
|
||||
.stimmen-einreichen-kachel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
padding: 1.5px;
|
||||
background: linear-gradient(115deg, rgba(255,154,208,.75), rgba(143,217,234,.75), rgba(199,204,209,.5), rgba(255,154,208,.75));
|
||||
background-size: 300% 100%;
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
animation: stimmen-rahmen-wandert 9s linear infinite;
|
||||
}
|
||||
@keyframes stimmen-rahmen-wandert { to { background-position: 300% 0; } }
|
||||
/* Schwebende Herz-Partikel — reines CSS, keine zusätzlichen Elemente nötig. */
|
||||
.stimmen-einreichen-glanz {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(255,154,208,.5) 2px, transparent 3px),
|
||||
radial-gradient(circle, rgba(143,217,234,.45) 2px, transparent 3px),
|
||||
radial-gradient(circle, rgba(255,154,208,.35) 1.5px, transparent 3px);
|
||||
background-size: 190px 190px, 260px 260px, 140px 140px;
|
||||
background-position: 12% 18%, 78% 62%, 44% 88%;
|
||||
animation: stimmen-partikel-treiben 16s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes stimmen-partikel-treiben {
|
||||
from { background-position: 12% 18%, 78% 62%, 44% 88%; opacity: .55; }
|
||||
to { background-position: 18% 26%, 71% 54%, 50% 79%; opacity: .95; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stimmen-einreichen-kachel::before, .stimmen-einreichen-glanz { animation: none; }
|
||||
}
|
||||
.stimmen-einreichen-kopf { text-align: center; margin-bottom: 1.8rem; }
|
||||
/* Medaillon statt nacktem Emoji: Kreis mit Farbschein und sanftem Puls. */
|
||||
.stimmen-einreichen-herz {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 74px; height: 74px;
|
||||
border-radius: 50%;
|
||||
font-size: 2.1rem;
|
||||
background: radial-gradient(circle at 35% 30%, rgba(255,154,208,.28), rgba(143,217,234,.12) 70%);
|
||||
border: 1.5px solid rgba(255, 154, 208, .45);
|
||||
box-shadow: 0 0 26px rgba(255, 154, 208, .3), inset 0 0 22px rgba(255, 154, 208, .12);
|
||||
animation: stimmen-herz-puls 3.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes stimmen-herz-puls {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 22px rgba(255,154,208,.22), inset 0 0 20px rgba(255,154,208,.1); }
|
||||
50% { transform: scale(1.06); box-shadow: 0 0 40px rgba(255,154,208,.45), inset 0 0 26px rgba(255,154,208,.18); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .stimmen-einreichen-herz { animation: none; } }
|
||||
.stimmen-einreichen-kopf h2 {
|
||||
margin: .9rem 0 .6rem;
|
||||
background: linear-gradient(100deg, #ffd0e8, #ffffff 40%, #a8e4f2);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.stimmen-einreichen-kopf p { color: var(--text-muted); }
|
||||
#stimmen-formular .field { margin-bottom: 1.1rem; }
|
||||
/* Eingabefelder leuchten beim Tippen in der Kachel-Farbwelt auf. */
|
||||
#stimmen-formular .field input,
|
||||
#stimmen-formular .field textarea {
|
||||
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
|
||||
}
|
||||
#stimmen-formular .field input:focus,
|
||||
#stimmen-formular .field textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 154, 208, .75);
|
||||
box-shadow: 0 0 0 3px rgba(255, 154, 208, .14), 0 0 22px rgba(255, 154, 208, .18);
|
||||
background: rgba(255, 255, 255, .06);
|
||||
}
|
||||
.stimmen-erfolg { color: #baf7d6; font-size: .9rem; margin-top: 1rem; display: none; text-align: center; }
|
||||
.stimmen-erfolg.zeige { display: block; }
|
||||
.stimmen-fehler { color: #ff9a9a; font-size: .88rem; margin-top: 1rem; display: none; text-align: center; }
|
||||
.stimmen-fehler.zeige { display: block; }
|
||||
|
||||
/* Freigegebene Stimmen (20.08.2026, 2. Runde: "und auch die die danach
|
||||
kommen wenn ich sie eingebe die sollen viel geiler sein"): echte
|
||||
Präsentationskarten statt schlichter Kästen — Farbschein-Bloom in der
|
||||
Ecke, großes Anführungszeichen als Wasserzeichen, farbige Akzentlinie
|
||||
oben, Avatar-Medaillon mit dem Anfangsbuchstaben, TikTok-Handle als
|
||||
eigener Chip. Beim Überfahren hebt sich die Karte leicht an. */
|
||||
.stimmen-karte {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 1.8rem 1.6rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
border: 1px solid rgba(143, 217, 234, .22);
|
||||
background:
|
||||
radial-gradient(ellipse at 100% 0%, rgba(255, 154, 208, .1), transparent 55%),
|
||||
linear-gradient(180deg, rgba(24, 34, 51, .9), rgba(15, 21, 33, .95));
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, .32);
|
||||
transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease;
|
||||
}
|
||||
.stimmen-karte:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(255, 154, 208, .5);
|
||||
box-shadow: 0 22px 52px rgba(0, 0, 0, .42), 0 0 30px rgba(255, 154, 208, .14);
|
||||
}
|
||||
/* Farbige Akzentlinie oben — greift die Farbwelt der Einreich-Kachel auf. */
|
||||
.stimmen-karte::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2.5px;
|
||||
background: linear-gradient(90deg, rgba(255,154,208,.9), rgba(143,217,234,.9));
|
||||
}
|
||||
/* Großes Anführungszeichen als dezentes Wasserzeichen im Hintergrund. */
|
||||
.stimmen-karte::before {
|
||||
content: "„";
|
||||
position: absolute;
|
||||
top: -.9rem; right: .6rem;
|
||||
z-index: -1;
|
||||
font-size: 8rem;
|
||||
font-family: Georgia, serif;
|
||||
color: rgba(143, 217, 234, .09);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stimmen-karte-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
margin: 0 0 1.3rem;
|
||||
line-height: 1.75;
|
||||
font-size: 1.02rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.stimmen-karte-fuss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .7rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, .07);
|
||||
}
|
||||
/* Avatar-Medaillon mit dem Anfangsbuchstaben (wird in stimmen.js gefüllt). */
|
||||
.stimmen-karte-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px; height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
color: #0d1420;
|
||||
background: linear-gradient(135deg, #ffd0e8, #a8e4f2);
|
||||
box-shadow: 0 0 16px rgba(255, 154, 208, .3);
|
||||
}
|
||||
.stimmen-karte-namen { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
|
||||
.stimmen-karte-name { font-weight: 700; color: var(--text); font-size: .95rem; }
|
||||
.stimmen-karte-tiktok {
|
||||
align-self: flex-start;
|
||||
font-size: .76rem;
|
||||
font-weight: 600;
|
||||
color: #a8e4f2;
|
||||
padding: .1rem .5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 217, 234, .12);
|
||||
border: 1px solid rgba(143, 217, 234, .28);
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 239 KiB |
@@ -0,0 +1,18 @@
|
||||
/* =====================================================================
|
||||
data-manager.js — Manager-Datenobjekt (21.08.2026)
|
||||
|
||||
DogFather (Filipe) selbst hat weiterhin seine eigene, feste Seite
|
||||
(manager.html) — dieses Array ist für WEITERE Manager gedacht, die
|
||||
künftig dazukommen könnten (siehe zeitreise.html, "Aufbau eines
|
||||
eigenen Creator-Systems": "Eine langfristige Struktur für Creator,
|
||||
Scouts und zukünftige Manager"). Noch niemand final geklärt, deshalb
|
||||
bewusst leer — genau wie data-creator.js, bis echte Namen feststehen.
|
||||
Einfach nach dem Muster in data-scouts.js Objekte ins Array einfügen,
|
||||
sobald es so weit ist — manager.html liest automatisch aus.
|
||||
|
||||
Felder: siehe data-scouts.js (identisches Muster).
|
||||
===================================================================== */
|
||||
|
||||
const MANAGERS = [];
|
||||
|
||||
if (typeof window !== "undefined") window.MANAGERS = MANAGERS;
|
||||
@@ -68,7 +68,7 @@ const MODIS = [
|
||||
bioHtml: {
|
||||
de: `
|
||||
<p>Hallo, ich bin <strong>Diene</strong> und auf TikTok findet ihr mich unter <strong>@_ditschi</strong>.</p>
|
||||
<p>Ich bin 37 Jahre alt, verheiratet und Mutter von zwei Kindern. Wer mich etwas besser kennt, weiß, dass ich manchmal ziemlich verpeilt und vergesslich sein kann. Und wer mich noch nicht kennt, wird es vermutlich ziemlich schnell bemerken. Genau das gehört aber auch einfach zu mir.</p>
|
||||
<p>Ich bin im August 1988 geboren, verheiratet und Mutter von zwei Kindern. Wer mich etwas besser kennt, weiß, dass ich manchmal ziemlich verpeilt und vergesslich sein kann. Und wer mich noch nicht kennt, wird es vermutlich ziemlich schnell bemerken. Genau das gehört aber auch einfach zu mir.</p>
|
||||
<p>Auf TikTok bin ich ungefähr seit dem Jahr 2020 aktiv. Anfangs habe ich hauptsächlich Videos angesehen und gelegentlich eigene Videos für mich erstellt.</p>
|
||||
<p>Ende April 2026 war ich zum ersten Mal in einem Livestream von Dogi. Seit Anfang Mai bin ich regelmäßig – meistens sogar täglich – dabei.</p>
|
||||
<h3>Warum bin ich geblieben?</h3>
|
||||
@@ -86,7 +86,7 @@ const MODIS = [
|
||||
`,
|
||||
"de-CH": `
|
||||
<p>Hoi, ich bi <strong>Diene</strong> und uf TikTok findet ihr mich unter <strong>@_ditschi</strong>.</p>
|
||||
<p>Ich bi 37 Jahr alt, verhürotet und Mueter vo zwöi Chind. Wer mich es bitzeli besser kennt, weiss, dass ich mängisch ziemlich verpeilt und vergesslich sii cha. Und wer mich no nöd kennt, wird's wahrschindlich ziemlich schnäll merke. Aber genau das ghört eifach au zu mir dezue.</p>
|
||||
<p>Ich bi im Auguscht 1988 gebore, verhürotet und Mueter vo zwöi Chind. Wer mich es bitzeli besser kennt, weiss, dass ich mängisch ziemlich verpeilt und vergesslich sii cha. Und wer mich no nöd kennt, wird's wahrschindlich ziemlich schnäll merke. Aber genau das ghört eifach au zu mir dezue.</p>
|
||||
<p>Uf TikTok bin ich öppe sit 2020 aktiv. Am Afang han ich hauptsächlich Videos aglueget und ab und zue mal es eigets Video für mich gmacht.</p>
|
||||
<p>Ändi April 2026 bin ich zum erste Mal i-me Livestream vom Dogi gsi. Sit Afang Mai bin ich regelmässig – meistens sogar täglich – debii.</p>
|
||||
<h3>Wieso bin ich blibe?</h3>
|
||||
@@ -104,7 +104,7 @@ const MODIS = [
|
||||
`,
|
||||
en: `
|
||||
<p>Hi, I'm <strong>Diene</strong>, and you can find me on TikTok as <strong>@_ditschi</strong>.</p>
|
||||
<p>I'm 37 years old, married, and a mother of two. Anyone who knows me a bit better knows that I can be pretty scatterbrained and forgetful sometimes. And anyone who doesn't know me yet will probably notice pretty quickly. But that's just part of who I am.</p>
|
||||
<p>I was born in August 1988, married, and a mother of two. Anyone who knows me a bit better knows that I can be pretty scatterbrained and forgetful sometimes. And anyone who doesn't know me yet will probably notice pretty quickly. But that's just part of who I am.</p>
|
||||
<p>I've been active on TikTok since around 2020. At first I mostly watched videos and occasionally made a few of my own just for fun.</p>
|
||||
<p>At the end of April 2026, I joined one of Dogi's livestreams for the very first time. Since the beginning of May, I've been there regularly — usually even daily.</p>
|
||||
<h3>Why did I stay?</h3>
|
||||
@@ -122,7 +122,7 @@ const MODIS = [
|
||||
`,
|
||||
fr: `
|
||||
<p>Salut, je suis <strong>Diene</strong> et vous me trouverez sur TikTok sous le pseudo <strong>@_ditschi</strong>.</p>
|
||||
<p>J'ai 37 ans, je suis mariée et maman de deux enfants. Ceux qui me connaissent un peu mieux savent que je peux être plutôt tête en l'air et distraite. Et ceux qui ne me connaissent pas encore le remarqueront sans doute assez vite. Mais ça fait tout simplement partie de moi.</p>
|
||||
<p>Je suis née en août 1988, mariée et maman de deux enfants. Ceux qui me connaissent un peu mieux savent que je peux être plutôt tête en l'air et distraite. Et ceux qui ne me connaissent pas encore le remarqueront sans doute assez vite. Mais ça fait tout simplement partie de moi.</p>
|
||||
<p>Je suis active sur TikTok depuis environ 2020. Au début, je regardais surtout des vidéos et je créais de temps en temps les miennes, juste pour moi.</p>
|
||||
<p>Fin avril 2026, j'ai assisté pour la première fois à un livestream de Dogi. Depuis début mai, j'y suis régulièrement — le plus souvent même tous les jours.</p>
|
||||
<h3>Pourquoi suis-je restée ?</h3>
|
||||
@@ -140,7 +140,7 @@ const MODIS = [
|
||||
`,
|
||||
pt: `
|
||||
<p>Olá, eu sou a <strong>Diene</strong> e podem encontrar-me no TikTok como <strong>@_ditschi</strong>.</p>
|
||||
<p>Tenho 37 anos, sou casada e mãe de dois filhos. Quem me conhece um pouco melhor sabe que às vezes posso ser bastante distraída e esquecida. E quem ainda não me conhece, provavelmente vai reparar nisso bem depressa. Mas isso também faz simplesmente parte de mim.</p>
|
||||
<p>Nasci em agosto de 1988, sou casada e mãe de dois filhos. Quem me conhece um pouco melhor sabe que às vezes posso ser bastante distraída e esquecida. E quem ainda não me conhece, provavelmente vai reparar nisso bem depressa. Mas isso também faz simplesmente parte de mim.</p>
|
||||
<p>Estou ativa no TikTok desde cerca de 2020. No início, via sobretudo vídeos e, de vez em quando, criava os meus próprios, só para mim.</p>
|
||||
<p>No final de abril de 2026, assisti pela primeira vez a uma livestream do Dogi. Desde o início de maio que estou presente regularmente — na maioria das vezes até diariamente.</p>
|
||||
<h3>Porque é que fiquei?</h3>
|
||||
|
||||
@@ -74,7 +74,7 @@ const SCOUTS = [
|
||||
<p>Als Scout unterstütze ich das Team dabei, neue Talente zu entdecken, ihnen zur Seite zu stehen und sie auf ihrem persönlichen Weg zu begleiten. Dabei geht es mir nicht nur um Zahlen oder Erfolge, sondern vor allem um die Menschen, ihre Ziele und ihre Entwicklung.</p>
|
||||
<p>Ich freue mich auf alles, was noch vor mir liegt: neue Begegnungen, besondere Erinnerungen und viele weitere gemeinsame Momente.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Das bin ich – Patrick. Nicht perfekt, manchmal mit einem grimmigen Blick, aber immer mit Herz. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– euer Patrick ❤️</strong></p>
|
||||
<p style="text-align:right;"><strong>– euer Patrick ❤️</strong><br />Scout von Dogfather</p>
|
||||
`,
|
||||
"de-CH": `
|
||||
<p>Im Oktober 2024 han ich schliesslich de nöchscht Schritt gwagt und bi zum erste Mal live gange. Sithär han ich zahlriichi Mönsche cha kenneleärne und e paar dervo fescht i mis Herz gschlosse. Genau die Begägnige und dr persönlich Uustuusch mached TikTok für mich zu öppisem Bsundrigem.</p>
|
||||
@@ -83,7 +83,7 @@ const SCOUTS = [
|
||||
<p>Als Scout unterstütz ich s Team derbi, neui Talänt z'entdecke, ihne zur Syte z'stah und sie uf ihrem persönliche Wäg z'begleite. Debii gaht's mir nöd nume um Zahle oder Erfolg, sondern vor allem um d Mönsche, ihri Ziel und ihri Entwicklig.</p>
|
||||
<p>Ich fröi mich uf alles, wo no vor mir liit: neui Begägnige, bsundrigi Erinnerige und no vill gmeinsami Momänt.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Das bin ich – Patrick. Nöd perfekt, mängisch mit emene grimmige Blick, aber immer mit Herz. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– öie Patrick ❤️</strong></p>
|
||||
<p style="text-align:right;"><strong>– öie Patrick ❤️</strong><br />Scout vom Dogfather</p>
|
||||
`,
|
||||
en: `
|
||||
<p>In October 2024, I finally took the next step and went live for the first time. Since then, I've gotten to know so many people, and some of them have found a firm place in my heart. It's exactly these encounters and the personal exchange that make TikTok something special to me.</p>
|
||||
@@ -92,7 +92,7 @@ const SCOUTS = [
|
||||
<p>As a scout, I help the team discover new talent, stand by their side, and guide them on their personal journey. For me, it's not just about numbers or success — it's above all about the people, their goals, and their growth.</p>
|
||||
<p>I'm looking forward to everything still ahead of me: new encounters, special memories, and many more moments together.</p>
|
||||
<p class="quote" style="margin-top:1rem;">That's me – Patrick. Not perfect, sometimes with a grumpy look, but always with heart. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– your Patrick ❤️</strong></p>
|
||||
<p style="text-align:right;"><strong>– your Patrick ❤️</strong><br />Scout for Dogfather</p>
|
||||
`,
|
||||
fr: `
|
||||
<p>En octobre 2024, j'ai finalement franchi l'étape suivante et j'ai fait mon premier live. Depuis, j'ai pu rencontrer de nombreuses personnes et certaines d'entre elles ont pris une vraie place dans mon cœur. Ce sont justement ces rencontres et ces échanges personnels qui rendent TikTok si particulier pour moi.</p>
|
||||
@@ -101,7 +101,7 @@ const SCOUTS = [
|
||||
<p>En tant que scout, j'aide l'équipe à découvrir de nouveaux talents, à les épauler et à les accompagner sur leur propre chemin. Ce qui compte pour moi, ce n'est pas seulement les chiffres ou le succès, mais avant tout les personnes, leurs objectifs et leur évolution.</p>
|
||||
<p>Je me réjouis de tout ce qui m'attend encore : de nouvelles rencontres, des souvenirs particuliers et bien d'autres moments partagés.</p>
|
||||
<p class="quote" style="margin-top:1rem;">C'est moi, Patrick. Pas parfait, parfois avec un regard grognon, mais toujours avec du cœur. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– votre Patrick ❤️</strong></p>
|
||||
<p style="text-align:right;"><strong>– votre Patrick ❤️</strong><br />Scout de Dogfather</p>
|
||||
`,
|
||||
pt: `
|
||||
<p>Em outubro de 2024, finalmente dei o próximo passo e fiz a minha primeira live. Desde então, pude conhecer muitas pessoas e algumas delas conquistaram um lugar especial no meu coração. São exatamente esses encontros e a troca pessoal que tornam o TikTok algo especial para mim.</p>
|
||||
@@ -110,11 +110,21 @@ const SCOUTS = [
|
||||
<p>Como scout, ajudo a equipe a descobrir novos talentos, a estar ao lado deles e a acompanhá-los no seu caminho pessoal. Para mim, não se trata apenas de números ou sucesso, mas sobretudo das pessoas, dos seus objetivos e do seu desenvolvimento.</p>
|
||||
<p>Estou animado com tudo o que ainda está por vir: novos encontros, memórias especiais e muitos outros momentos em conjunto.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Este sou eu – Patrick. Não sou perfeito, às vezes com um olhar carrancudo, mas sempre com coração. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– o seu Patrick ❤️</strong></p>
|
||||
<p style="text-align:right;"><strong>– o seu Patrick ❤️</strong><br />Scout do Dogfather</p>
|
||||
`,
|
||||
},
|
||||
quote: "", // TODO: Zitat von Patrick einholen
|
||||
social: {},
|
||||
quote: {
|
||||
de: "Wege entstehen dadurch, dass man sie geht.",
|
||||
"de-CH": "Wäg entstönd defür, dass mer sie gaht.",
|
||||
en: "Paths are created by walking them.",
|
||||
fr: "Les chemins se créent en marchant.",
|
||||
pt: "Os caminhos se fazem ao caminhar.",
|
||||
},
|
||||
// TikTok-Button auf dem Profil (Nutzer-Wunsch 21.08.2026) -- profil.html
|
||||
// rendert daraus automatisch den echten .btn-tiktok-Button unter dem
|
||||
// Namen/Rolle-Text, kein weiterer Code-Umbau nötig (siehe socialLabels
|
||||
// in profil.html).
|
||||
social: { tiktok: "https://www.tiktok.com/@patrick180585" },
|
||||
status: "aktiv",
|
||||
},
|
||||
{
|
||||
@@ -294,7 +304,8 @@ const SCOUTS = [
|
||||
de: "Ein Mann, ein Wort.", "de-CH": "Bi mir gilt: gseit isch gseit.",
|
||||
en: "My word is my bond.", fr: "Chose promise, chose due.", pt: "A minha palavra é a minha honra.",
|
||||
},
|
||||
social: {},
|
||||
// TikTok-Button auf dem Profil (Nutzer-Wunsch 21.08.2026), siehe Kommentar bei Patrick oben.
|
||||
social: { tiktok: "https://www.tiktok.com/@bananenstift009" },
|
||||
status: "aktiv",
|
||||
},
|
||||
// TODO: Dogi liefert die vollständige, aktuelle Scout-Liste (Namen +
|
||||
|
||||
@@ -29,12 +29,15 @@ window.I18N_PAGE = {
|
||||
fr: "Cet abonnement n'est ni une boutique ordinaire ni un simple achat. Il est destiné aux personnes qui soutiennent Dogfather durablement et souhaitent faire partie de ce voyage commun.",
|
||||
pt: "Esta assinatura não é uma loja comum nem uma compra simples. É para as pessoas que apoiam Dogfather a longo prazo e querem ser parte especial dessa jornada em comum.",
|
||||
},
|
||||
/* Bugfix/Nutzer-Wunsch 20.08.2026: exakter Preis entfernt (nur die erste
|
||||
Teilphrase geändert, Rest jeder Sprachfassung unangetastet), solange die
|
||||
Registrierung gesperrt ist — siehe .btn-gold-locked/abo-preis-zahl. */
|
||||
ab_text2: {
|
||||
de: "Für 4,99 € im Monat erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.",
|
||||
"de-CH": "Für 4,99 € im Monat überchunnsch du Zugang zu emene exklusive Supporter-Bereich, bsundere Inhalt und ere persönliche Treuekollektion. Mit jedem erfolgriich bezahlte Meilestei wachst dini Sammlig: vom chline Hase-Teddy über bsundere Jubiläumsprämie bis zum exklusive Hoodie nach 30 Monet. Dernah fangt für dich e neui Kollektion a — mit neue Designs, neue Erinnerige und derselbe Bedeutig.",
|
||||
en: "For 4.99 € a month, you get access to an exclusive supporter area, special content, and a personal loyalty collection. With every successfully paid milestone, your collection grows — from the small bunny teddy through special anniversary rewards up to the exclusive hoodie after 30 months. After that, a new collection begins for you, with new designs, new memories, and the same meaning.",
|
||||
fr: "Pour 4,99 € par mois, tu as accès à un espace supporter exclusif, à du contenu spécial et à une collection de fidélité personnelle. Avec chaque mois payé avec succès, ta collection grandit : du petit lapin en peluche aux récompenses d'anniversaire jusqu'au hoodie exclusif après 30 mois. Ensuite, une nouvelle collection commence pour toi — avec de nouveaux designs, de nouveaux souvenirs et la même signification.",
|
||||
pt: "Por 4,99 € por mês, você tem acesso a uma área exclusiva de apoiadores, conteúdo especial e uma coleção pessoal de fidelidade. A cada mês pago com sucesso, sua coleção cresce: do pequeno coelhinho de pelúcia até recompensas especiais de aniversário e o hoodie exclusivo após 30 meses. Depois disso, começa uma nova coleção para você — com novos designs, novas memórias e o mesmo significado.",
|
||||
de: "Mit deinem monatlichen Beitrag erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.",
|
||||
"de-CH": "Mit dim monetliche Bytrag überchunnsch du Zugang zu emene exklusive Supporter-Bereich, bsundere Inhalt und ere persönliche Treuekollektion. Mit jedem erfolgriich bezahlte Meilestei wachst dini Sammlig: vom chline Hase-Teddy über bsundere Jubiläumsprämie bis zum exklusive Hoodie nach 30 Monet. Dernah fangt für dich e neui Kollektion a — mit neue Designs, neue Erinnerige und derselbe Bedeutig.",
|
||||
en: "With your monthly contribution, you get access to an exclusive supporter area, special content, and a personal loyalty collection. With every successfully paid milestone, your collection grows — from the small bunny teddy through special anniversary rewards up to the exclusive hoodie after 30 months. After that, a new collection begins for you, with new designs, new memories, and the same meaning.",
|
||||
fr: "Avec ta contribution mensuelle, tu as accès à un espace supporter exclusif, à du contenu spécial et à une collection de fidélité personnelle. Avec chaque mois payé avec succès, ta collection grandit : du petit lapin en peluche aux récompenses d'anniversaire jusqu'au hoodie exclusif après 30 mois. Ensuite, une nouvelle collection commence pour toi — avec de nouveaux designs, de nouveaux souvenirs et la même signification.",
|
||||
pt: "Com sua contribuição mensal, você tem acesso a uma área exclusiva de apoiadores, conteúdo especial e uma coleção pessoal de fidelidade. A cada mês pago com sucesso, sua coleção cresce: do pequeno coelhinho de pelúcia até recompensas especiais de aniversário e o hoodie exclusivo após 30 meses. Depois disso, começa uma nova coleção para você — com novos designs, novas memórias e o mesmo significado.",
|
||||
},
|
||||
ab_vorteil1: {
|
||||
de: "Exklusiver Supporter-Bereich", "de-CH": "Exklusive Supporter-Bereich",
|
||||
|
||||
@@ -8,12 +8,18 @@ window.I18N_PAGE = {
|
||||
de: "Werde Teil vom Team", "de-CH": "Wird Teil vom Team",
|
||||
en: "Join the team", fr: "Rejoins l'équipe", pt: "Faça parte da equipe",
|
||||
},
|
||||
// Nutzer-Wunsch 21.08.2026: das letzte Wort stand auf manchen Bildschirmbreiten
|
||||
// ganz allein in einer eigenen Zeile ("Weg" unter sich, siehe Screenshot) --
|
||||
// sieht typografisch unruhig aus. zwischen den letzten zwei Wörtern
|
||||
// verhindert, dass genau DAZWISCHEN umgebrochen wird: fällt eine Zeile um,
|
||||
// dann nehmen die letzten zwei Wörter sich gegenseitig mit statt eines davon
|
||||
// einsam zurückzulassen.
|
||||
bw_h1: {
|
||||
de: "Community oder Agentur — such dir deinen Weg",
|
||||
"de-CH": "Community oder Agentur — such dir dis Wäg",
|
||||
en: "Community or agency — find your path",
|
||||
fr: "Communauté ou agence — trouve ta voie",
|
||||
pt: "Comunidade ou agência — encontre o seu caminho",
|
||||
de: "Community oder Agentur — such dir deinen Weg",
|
||||
"de-CH": "Community oder Agentur — such dir dis Wäg",
|
||||
en: "Community or agency — find your path",
|
||||
fr: "Communauté ou agence — trouve ta voie",
|
||||
pt: "Comunidade ou agência — encontre o seu caminho",
|
||||
},
|
||||
bw_lead: {
|
||||
de: "Vier Bewerbungen, drei ganz unterschiedliche Rollen. Wähl, was zu dir passt.",
|
||||
|
||||
@@ -38,14 +38,6 @@ window.I18N_PAGE = {
|
||||
de: "Discord beitreten", "de-CH": "Bi Discord mitmache",
|
||||
en: "Join Discord", fr: "Rejoindre le Discord", pt: "Entrar no Discord",
|
||||
},
|
||||
com_bild_platzhalter: {
|
||||
de: "Community-Bild", "de-CH": "Community-Bild",
|
||||
en: "Community Photo", fr: "Photo de la communauté", pt: "Foto da comunidade",
|
||||
},
|
||||
com_foto_folgt: {
|
||||
de: "Foto folgt", "de-CH": "Foto chunnt no",
|
||||
en: "Photo coming soon", fr: "Photo à venir", pt: "Foto em breve",
|
||||
},
|
||||
com_story_h2: {
|
||||
de: "Team Dogi – mehr als eine Community 🩵", "de-CH": "Team Dogi – meh als e Community 🩵",
|
||||
en: "Team Dogi – More Than a Community 🩵", fr: "Team Dogi – bien plus qu'une communauté 🩵", pt: "Team Dogi – mais do que uma comunidade 🩵",
|
||||
|
||||
@@ -39,6 +39,13 @@ window.I18N_PAGE = {
|
||||
de: "Profil ansehen", "de-CH": "Profil aluege",
|
||||
en: "View profile", fr: "Voir le profil", pt: "Ver perfil",
|
||||
},
|
||||
/* 21.08.2026: Fallback-Kategorie für über die Verwaltung angelegte
|
||||
Creator ohne eigenes Genre (die Verwaltung fragt aktuell keine
|
||||
Genre-Kategorie ab) -- verhindert einen "undefined"-Filterknopf. */
|
||||
crt_kategorie_sonstige: {
|
||||
de: "Sonstige", "de-CH": "Anders",
|
||||
en: "Other", fr: "Autre", pt: "Outro",
|
||||
},
|
||||
crt_bewerben_btn: {
|
||||
de: "Als Creator bewerben", "de-CH": "Als Creator bewärbe",
|
||||
en: "Apply as a creator", fr: "Postuler comme créateur", pt: "Candidatar-se como creator",
|
||||
|
||||
@@ -37,6 +37,10 @@ window.I18N_PAGE = {
|
||||
de: "🐰 HasiDog – Eine eigene Welt voller Chaos, Herz und Fantasie", "de-CH": "🐰 HasiDog – E eigeti Wält vollr Chaos, Herz und Fantasie",
|
||||
en: "🐰 HasiDog – A world of its own, full of chaos, heart and imagination", fr: "🐰 HasiDog – Un monde à part, plein de chaos, de cœur et d'imagination", pt: "🐰 HasiDog – Um mundo à parte, cheio de caos, coração e imaginação",
|
||||
},
|
||||
hd_btn_tiktok: {
|
||||
de: "Zur HasiDog TikTok-Seite", "de-CH": "Zur HasiDog TikTok-Site",
|
||||
en: "Go to HasiDog's TikTok page", fr: "Voir la page TikTok de HasiDog", pt: "Ir para a página TikTok do HasiDog",
|
||||
},
|
||||
hd_welt_1: {
|
||||
de: "HasiDog ist mehr als nur eine Figur. Hinter HasiDog steckt eine eigene Welt aus Humor, Musik, Geschichten, Überraschungen und liebenswertem Chaos. 🩵",
|
||||
"de-CH": "HasiDog isch meh als nur e Figur. Hinter HasiDog steckt e eigeti Wält us Humor, Musig, Gschichte, Überraschige und liebenswerte Chaos. 🩵",
|
||||
|
||||
@@ -81,6 +81,23 @@ window.I18N_PAGE = {
|
||||
fr: "En tant que manager chez Spicy Media : scouts, creators et une collaboration équitable, d'égal à égal.",
|
||||
pt: "Como manager na Spicy Media: scouts, creators e uma parceria justa entre iguais.",
|
||||
},
|
||||
/* ---------- "Event des Jahres" (20.08.2026, Nutzer-Wunsch) — volle Breite unter den
|
||||
3 Welt-Kacheln, zeigt bewusst IMMER genau zwei Events. Texte sind gekürzte, inhaltlich
|
||||
unveränderte Fassungen der ausführlichen Beschreibungen aus i18n-zeitreise.js
|
||||
(zr_ev4_h3/p, zr_ev9_h3/p) — dieselben zwei Events, nur kompakter für die Kachel. ---------- */
|
||||
idx_jahres_events_tag: {
|
||||
de: "🏆 Event des Jahres", "de-CH": "🏆 Event vom Jahr",
|
||||
en: "🏆 Event of the Year", fr: "🏆 Événement de l'année", pt: "🏆 Evento do ano",
|
||||
},
|
||||
idx_jahres_events_mehr: {
|
||||
de: "Mehr erfahren →", "de-CH": "Mehr erfahre →",
|
||||
en: "Learn more →", fr: "En savoir plus →", pt: "Saiba mais →",
|
||||
},
|
||||
idx_jahres_event_modal_link: {
|
||||
de: "Direkt ansehen →", "de-CH": "Direkt aluege →",
|
||||
en: "View directly →", fr: "Voir directement →", pt: "Ver diretamente →",
|
||||
},
|
||||
|
||||
idx_char_eyebrow: {
|
||||
de: "🩵 Die Gesichter der Welten", "de-CH": "🩵 D Gsichter vo de Wälte",
|
||||
en: "🩵 The Faces of the Worlds", fr: "🩵 Les visages des mondes", pt: "🩵 Os rostos dos mundos",
|
||||
@@ -187,14 +204,6 @@ window.I18N_PAGE = {
|
||||
de: "Modi-Team ansehen", "de-CH": "Modi-Team aluege",
|
||||
en: "View Modi-Team", fr: "Voir l'équipe Modi", pt: "Ver equipe Modi",
|
||||
},
|
||||
idx_aktionen_h2: {
|
||||
de: "Was gerade läuft", "de-CH": "Was grad lauft",
|
||||
en: "What's happening right now", fr: "Ce qui se passe en ce moment", pt: "O que está rolando agora",
|
||||
},
|
||||
idx_aktionen_btn: {
|
||||
de: "Alle Aktionen & Projekte", "de-CH": "Alli Aktione & Projekt",
|
||||
en: "All actions & projects", fr: "Toutes les actions et projets", pt: "Todas as ações e projetos",
|
||||
},
|
||||
idx_galerie_h2: { de: "Impressionen", "de-CH": "Impressione", en: "Impressions", fr: "Aperçus", pt: "Impressões" },
|
||||
idx_galerie_btn: {
|
||||
de: "Zur ganzen Galerie", "de-CH": "Zur ganze Galerie",
|
||||
|
||||
@@ -177,4 +177,16 @@ window.I18N_PAGE = {
|
||||
fr: "Les scouts trouvent et accompagnent de nouveaux talents pour Dogi.",
|
||||
pt: "Os scouts encontram e acompanham novos talentos para o Dogi.",
|
||||
},
|
||||
/* 21.08.2026: Abschnitt für weitere, über die Verwaltung angelegte Manager
|
||||
(siehe zeitreise.html, "Aufbau eines eigenen Creator-Systems" ->
|
||||
"zukünftige Manager"). Bleibt komplett unsichtbar, solange keiner
|
||||
eingetragen ist -- siehe renderWeitereManager() weiter unten. */
|
||||
mgr_weitere_h2: {
|
||||
de: "Weitere Manager", "de-CH": "Witeri Manager",
|
||||
en: "More Managers", fr: "Autres managers", pt: "Outros managers",
|
||||
},
|
||||
mgr_weitere_profil_btn: {
|
||||
de: "Profil ansehen", "de-CH": "Profil aluege",
|
||||
en: "View profile", fr: "Voir le profil", pt: "Ver perfil",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,6 +30,10 @@ window.I18N_PAGE = {
|
||||
de: "Manager DogFather — Creator", "de-CH": "Manager DogFather — Creator",
|
||||
en: "Manager DogFather — Creator", fr: "Manager DogFather — Creator", pt: "Manager DogFather — Creator",
|
||||
},
|
||||
pr_eyebrow_manager: {
|
||||
de: "Manager DogFather — Spicy Media", "de-CH": "Manager DogFather — Spicy Media",
|
||||
en: "Manager DogFather — Spicy Media", fr: "Manager DogFather — Spicy Media", pt: "Manager DogFather — Spicy Media",
|
||||
},
|
||||
pr_back_modis: {
|
||||
de: "← Zurück zum Modi-Team", "de-CH": "← Zrugg zum Modi-Team",
|
||||
en: "← Back to the Modi Team", fr: "← Retour à l'équipe Modi", pt: "← Voltar à equipe Modi",
|
||||
@@ -42,6 +46,10 @@ window.I18N_PAGE = {
|
||||
de: "← Zurück zu den Creatorn", "de-CH": "← Zrugg zu de Creator",
|
||||
en: "← Back to the Creators", fr: "← Retour aux Creators", pt: "← Voltar aos Creators",
|
||||
},
|
||||
pr_back_manager: {
|
||||
de: "← Zurück zu Manager DogFather", "de-CH": "← Zrugg zu Manager DogFather",
|
||||
en: "← Back to Manager DogFather", fr: "← Retour à Manager DogFather", pt: "← Voltar a Manager DogFather",
|
||||
},
|
||||
pr_badge_prefix: {
|
||||
de: "Stufe", "de-CH": "Stuefe",
|
||||
en: "Level", fr: "Niveau", pt: "Nível",
|
||||
|
||||
@@ -16,37 +16,47 @@ window.I18N_PAGE = {
|
||||
de: "Was Community und Team sagen.", "de-CH": "Was d Community und s Team säge.",
|
||||
en: "What the community and the team say.", fr: "Ce que disent la communauté et l'équipe.", pt: "O que a comunidade e a equipe dizem.",
|
||||
},
|
||||
st_quote_community: {
|
||||
de: `„Hier steht bald eine echte Stimme aus der Community. 🩵"`,
|
||||
"de-CH": `„Da staht bald e echti Stimm us de Community. 🩵"`,
|
||||
en: `"A real voice from the community will be here soon. 🩵"`,
|
||||
fr: `« Un vrai témoignage de la communauté arrivera bientôt ici. 🩵 »`,
|
||||
pt: `„Em breve, uma voz real da comunidade estará aqui. 🩵"`,
|
||||
/* Eigene Stimme einreichen (20.08.2026) */
|
||||
st_form_titel: {
|
||||
de: "Deine Stimme zählt", "de-CH": "Dini Stimm zählt",
|
||||
en: "Your Voice Matters", fr: "Ta voix compte", pt: "Sua voz importa",
|
||||
},
|
||||
st_label_community: {
|
||||
de: "— Community-Mitglied", "de-CH": "— Community-Mitglied",
|
||||
en: "— Community Member", fr: "— Membre de la communauté", pt: "— Membro da comunidade",
|
||||
st_form_text: {
|
||||
de: "Was bedeutet dir Team Dogi? Schreib uns deine Geschichte — mit etwas Glück liest DogFather sie persönlich und stellt sie hier auf die Seite.",
|
||||
"de-CH": "Was bedütet dir Team Dogi? Schriib eus dini Gschicht — mit chli Glück list DogFather sie persönlich und stellt sie da uf d Site.",
|
||||
en: "What does Team Dogi mean to you? Write us your story — with a bit of luck, DogFather will read it personally and feature it here on the site.",
|
||||
fr: "Que représente Team Dogi pour toi ? Écris-nous ton histoire — avec un peu de chance, DogFather la lira personnellement et la publiera ici.",
|
||||
pt: "O que a Team Dogi significa para você? Escreva sua história — com um pouco de sorte, o DogFather vai ler pessoalmente e publicar aqui.",
|
||||
},
|
||||
st_quote_team: {
|
||||
de: `„Hier steht bald eine echte Stimme aus dem Team. 🩵"`,
|
||||
"de-CH": `„Da staht bald e echti Stimm vom Team. 🩵"`,
|
||||
en: `"A real voice from the team will be here soon. 🩵"`,
|
||||
fr: `« Un vrai témoignage de l'équipe arrivera bientôt ici. 🩵 »`,
|
||||
pt: `„Em breve, uma voz real da equipe estará aqui. 🩵"`,
|
||||
st_form_name_label: {
|
||||
de: "Dein Name", "de-CH": "Dis Name",
|
||||
en: "Your Name", fr: "Ton nom", pt: "Seu nome",
|
||||
},
|
||||
st_label_team: {
|
||||
de: "— Team-Mitglied", "de-CH": "— Team-Mitglied",
|
||||
en: "— Team Member", fr: "— Membre de l'équipe", pt: "— Membro da equipe",
|
||||
st_form_tiktok_label: {
|
||||
de: "TikTok-Name (optional)", "de-CH": "TikTok-Name (optional)",
|
||||
en: "TikTok Username (optional)", fr: "Nom TikTok (facultatif)", pt: "Nome no TikTok (opcional)",
|
||||
},
|
||||
st_quote_scout: {
|
||||
de: `„Hier steht bald eine echte Stimme von Scouts & Creatorn. 🩵"`,
|
||||
"de-CH": `„Da staht bald e echti Stimm vo Scouts & Creator. 🩵"`,
|
||||
en: `"A real voice from scouts & creators will be here soon. 🩵"`,
|
||||
fr: `« Un vrai témoignage de scouts et créateurs arrivera bientôt ici. 🩵 »`,
|
||||
pt: `„Em breve, uma voz real de scouts e criadores estará aqui. 🩵"`,
|
||||
st_form_nachricht_label: {
|
||||
de: "Deine Nachricht", "de-CH": "Dini Nachricht",
|
||||
en: "Your Message", fr: "Ton message", pt: "Sua mensagem",
|
||||
},
|
||||
st_label_scout: {
|
||||
de: "— Scout/Creator", "de-CH": "— Scout/Creator",
|
||||
en: "— Scout/Creator", fr: "— Scout/Créateur", pt: "— Scout/Criador",
|
||||
st_form_nachricht_placeholder: {
|
||||
de: "Erzähl uns, was dir Team Dogi bedeutet …", "de-CH": "Verzell eus, was dir Team Dogi bedütet …",
|
||||
en: "Tell us what Team Dogi means to you …", fr: "Dis-nous ce que Team Dogi représente pour toi …", pt: "Conte-nos o que a Team Dogi significa para você …",
|
||||
},
|
||||
st_form_absenden: {
|
||||
de: "💙 Nachricht einreichen", "de-CH": "💙 Nachricht ischicke",
|
||||
en: "💙 Submit Message", fr: "💙 Envoyer le message", pt: "💙 Enviar mensagem",
|
||||
},
|
||||
st_form_erfolg: {
|
||||
de: "Danke dir! Deine Nachricht wurde eingereicht und wird von Dogi persönlich geprüft. 🩵",
|
||||
"de-CH": "Danke dir! Dini Nachricht isch ischickt worde und wird vo Dogi persönlich gprüeft. 🩵",
|
||||
en: "Thank you! Your message has been submitted and will be reviewed by Dogi personally. 🩵",
|
||||
fr: "Merci ! Ton message a été envoyé et sera examiné personnellement par Dogi. 🩵",
|
||||
pt: "Obrigado! Sua mensagem foi enviada e será revisada pessoalmente pelo Dogi. 🩵",
|
||||
},
|
||||
st_echte_titel: {
|
||||
de: "Das sagt die Community", "de-CH": "Das seit d Community",
|
||||
en: "What the Community Says", fr: "Ce que dit la communauté", pt: "O que a comunidade diz",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,44 @@ window.I18N_PAGE = {
|
||||
de: "Zur Dogi TikTok-Seite", "de-CH": "Zur Dogi TikTok-Site",
|
||||
en: "Go to Dogi's TikTok page", fr: "Voir la page TikTok de Dogi", pt: "Ir para a página TikTok do Dogi",
|
||||
},
|
||||
|
||||
/* Live-Radar (20.08.2026) */
|
||||
sp_radar_loading: {
|
||||
de: "Lädt …", "de-CH": "Lädt …",
|
||||
en: "Loading …", fr: "Chargement …", pt: "Carregando …",
|
||||
},
|
||||
sp_radar_offline: {
|
||||
de: "Nächster Fix-Stream in", "de-CH": "Nächschte Fix-Stream in",
|
||||
en: "Next fixed stream in", fr: "Prochain live fixe dans", pt: "Próxima live fixa em",
|
||||
},
|
||||
sp_radar_countdown_label: {
|
||||
de: "täglich ab 20 Uhr", "de-CH": "täglich ab 20 Uhr",
|
||||
en: "daily from 8 PM", fr: "chaque jour à 20h", pt: "diariamente às 20h",
|
||||
},
|
||||
sp_radar_live: {
|
||||
de: "🔴 DogFather ist LIVE", "de-CH": "🔴 DogFather isch LIVE",
|
||||
en: "🔴 DogFather is LIVE", fr: "🔴 DogFather est EN DIRECT", pt: "🔴 DogFather está AO VIVO",
|
||||
},
|
||||
sp_radar_live_generic: {
|
||||
de: "Jetzt live auf TikTok", "de-CH": "Jetzt live uf TikTok",
|
||||
en: "Live now on TikTok", fr: "En direct maintenant sur TikTok", pt: "Ao vivo agora no TikTok",
|
||||
},
|
||||
sp_radar_watch_btn: {
|
||||
de: "🔴 Jetzt anschauen", "de-CH": "🔴 Jetzt aluege",
|
||||
en: "🔴 Watch now", fr: "🔴 Regarder maintenant", pt: "🔴 Assistir agora",
|
||||
},
|
||||
sp_radar_hinweis: {
|
||||
de: "Alle Zeiten in deiner Ortszeit.", "de-CH": "Alli Ziite in dinere Ortsziit.",
|
||||
en: "All times shown in your local time.", fr: "Tous les horaires affichés dans ton fuseau local.", pt: "Todos os horários na sua hora local.",
|
||||
},
|
||||
sp_radar_hinweis_spezial: {
|
||||
de: "Spezielles Live-Event", "de-CH": "Spezielles Live-Event",
|
||||
en: "Special live event", fr: "Événement en direct spécial", pt: "Evento ao vivo especial",
|
||||
},
|
||||
sp_radar_mehr_erfahren: {
|
||||
de: "Mehr erfahren →", "de-CH": "Mehr erfahre →",
|
||||
en: "Learn more →", fr: "En savoir plus →", pt: "Saiba mais →",
|
||||
},
|
||||
sp_card1_tag: {
|
||||
de: "Fixer Termin", "de-CH": "Fixe Termin",
|
||||
en: "Fixed Time", fr: "Rendez-vous fixe", pt: "Horário Fixo",
|
||||
|
||||
@@ -102,4 +102,30 @@ window.I18N_PAGE = {
|
||||
de: "Zur DogiCrew 🩵", "de-CH": "Zur DogiCrew 🩵",
|
||||
en: "Go to the DogiCrew 🩵", fr: "Aller à la DogiCrew 🩵", pt: "Ir para a DogiCrew 🩵",
|
||||
},
|
||||
|
||||
/* Supporter-Abstimmungen (20.08.2026) */
|
||||
su_poll_eyebrow: {
|
||||
de: "🗳️ Aktuelle Abstimmung", "de-CH": "🗳️ Aktueui Abstimmig",
|
||||
en: "🗳️ Current Poll", fr: "🗳️ Sondage en cours", pt: "🗳️ Enquete atual",
|
||||
},
|
||||
su_poll_btn_abstimmen: {
|
||||
de: "Abstimmen", "de-CH": "Abstimme",
|
||||
en: "Vote", fr: "Voter", pt: "Votar",
|
||||
},
|
||||
su_poll_bitte_waehlen: {
|
||||
de: "Bitte wähle eine Antwort aus.", "de-CH": "Bitte wähl e Antwort us.",
|
||||
en: "Please choose an answer.", fr: "Merci de choisir une réponse.", pt: "Por favor, escolha uma resposta.",
|
||||
},
|
||||
su_poll_danke: {
|
||||
de: "Danke, deine Stimme wurde gezählt! 💙", "de-CH": "Danke, dini Stimm isch zellt worde! 💙",
|
||||
en: "Thanks, your vote was counted! 💙", fr: "Merci, ton vote a été pris en compte ! 💙", pt: "Obrigado, seu voto foi contado! 💙",
|
||||
},
|
||||
su_poll_stimmen: {
|
||||
de: "Stimmen", "de-CH": "Stimme",
|
||||
en: "votes", fr: "votes", pt: "votos",
|
||||
},
|
||||
su_poll_fehler: {
|
||||
de: "Abstimmen hat leider nicht geklappt. Bitte versuch es nochmal.", "de-CH": "Abstimme het leider nid klappt. Bitte versuech's nomol.",
|
||||
en: "Voting didn't work. Please try again.", fr: "Le vote a échoué. Merci de réessayer.", pt: "A votação falhou. Por favor, tente novamente.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,10 @@ window.I18N_PAGE = {
|
||||
fr: "L'ossature de Team Dogi — de VanVan à Marina.",
|
||||
pt: "A espinha dorsal do Team Dogi — de VanVan a Marina.",
|
||||
},
|
||||
tm_btn_tiktok_modigang: {
|
||||
de: "Zur Modi-Gang TikTok-Seite", "de-CH": "Zur Modi-Gang TikTok-Site",
|
||||
en: "Go to the Modi Gang TikTok page", fr: "Voir la page TikTok de la Modi-Gang", pt: "Ir para a página TikTok da Modi-Gang",
|
||||
},
|
||||
tm_tag_hervorgehoben: {
|
||||
de: "Hervorgehoben", "de-CH": "Ganz vorne dra",
|
||||
en: "Featured", fr: "En vedette", pt: "Em destaque",
|
||||
|
||||
@@ -24,7 +24,24 @@ window.I18N_PAGE = {
|
||||
en: "Date coming soon", fr: "Date à venir", pt: "Data em breve",
|
||||
},
|
||||
|
||||
/* ---------- Station 1 — Datum folgt ---------- */
|
||||
/* ---------- "Wächst noch"-Hinweis (20.08.2026) ---------- */
|
||||
zr_baustelle_h3: {
|
||||
de: "Diese Zeitreise wächst noch", "de-CH": "Die Ziitreis wachst no",
|
||||
en: "This timeline is still growing", fr: "Ce voyage dans le temps continue de grandir", pt: "Esta linha do tempo ainda está crescendo",
|
||||
},
|
||||
zr_baustelle_p: {
|
||||
de: "Wir ergänzen laufend weitere Kapitel, Daten und Momente aus der Geschichte von DogFather & Team Dogi — schau gern öfter mal wieder vorbei.",
|
||||
"de-CH": "Mir ergänzed laufend wiiteri Kapitel, Date und Momänt us de Gschicht vo DogFather & Team Dogi — schau gärn öppis öfters wieder verbi.",
|
||||
en: "We're continuously adding more chapters, dates, and moments from the story of DogFather & Team Dogi — feel free to check back again soon.",
|
||||
fr: "Nous ajoutons en permanence de nouveaux chapitres, dates et moments de l'histoire de DogFather & Team Dogi — n'hésite pas à revenir régulièrement.",
|
||||
pt: "Estamos adicionando continuamente mais capítulos, datas e momentos da história de DogFather & Team Dogi — volte sempre para conferir.",
|
||||
},
|
||||
|
||||
/* ---------- Station 1 — 24. Oktober 2023 ---------- */
|
||||
zr_ev1_date: {
|
||||
de: "24. Oktober 2023", "de-CH": "24. Oktober 2023",
|
||||
en: "October 24, 2023", fr: "24 octobre 2023", pt: "24 de outubro de 2023",
|
||||
},
|
||||
zr_ev1_h3: {
|
||||
de: "Start als Content Creator", "de-CH": "Start als Content Creator",
|
||||
en: "Start as a Content Creator", fr: "Débuts en tant que créateur de contenu", pt: "Início como criador de conteúdo",
|
||||
@@ -37,7 +54,11 @@ window.I18N_PAGE = {
|
||||
pt: "DogFather começa sua jornada no TikTok. A partir dos primeiros vídeos e lives, um estilo de conteúdo próprio vai surgindo aos poucos, cheio de humor, personalidade e proximidade real com a comunidade.",
|
||||
},
|
||||
|
||||
/* ---------- Station 2 — Datum folgt ---------- */
|
||||
/* ---------- Station 2 — Anfang 2025 ---------- */
|
||||
zr_ev2_date: {
|
||||
de: "Anfang 2025", "de-CH": "Aafang 2025",
|
||||
en: "Early 2025", fr: "Début 2025", pt: "Início de 2025",
|
||||
},
|
||||
zr_ev2_h3: {
|
||||
de: "Team Dogi entsteht", "de-CH": "Team Dogi entstaht",
|
||||
en: "Team Dogi Is Born", fr: "Naissance de Team Dogi", pt: "Nasce a Team Dogi",
|
||||
@@ -84,23 +105,6 @@ window.I18N_PAGE = {
|
||||
pt: "DogFather encara um desafio especial e transmite ao vivo por 28 horas seguidas. Uma experiência intensa, cheia de diversão, momentos especiais e muita união na comunidade.",
|
||||
},
|
||||
|
||||
/* ---------- Station 5 — Mai 2026 ---------- */
|
||||
zr_ev5_date: {
|
||||
de: "Mai 2026", "de-CH": "Mai 2026",
|
||||
en: "May 2026", fr: "Mai 2026", pt: "Maio de 2026",
|
||||
},
|
||||
zr_ev5_h3: {
|
||||
de: "Ein neues Kapitel beginnt", "de-CH": "Es neus Kapitel fangt a",
|
||||
en: "A New Chapter Begins", fr: "Un nouveau chapitre commence", pt: "Um novo capítulo começa",
|
||||
},
|
||||
zr_ev5_p: {
|
||||
de: "DogFather beendet die Zusammenarbeit mit seiner bisherigen Agentur und entscheidet sich dazu, seinen weiteren Weg neu auszurichten. Der Fokus liegt dabei auf persönlicher Weiterentwicklung, neuen Zielen und mehr Eigenständigkeit.",
|
||||
"de-CH": "DogFather beendet d Zämearbeit mit sinere bisherige Agentur und entscheidet sich derfür, sim wiitere Wäg neu uszrichte. De Fokus liit derbi uf persönlicher Wiiterentwicklig, neue Ziel und meh Eigeständigkeit.",
|
||||
en: "DogFather ends his collaboration with his previous agency and decides to realign his path. The focus shifts to personal growth, new goals, and greater independence.",
|
||||
fr: "DogFather met fin à sa collaboration avec son agence précédente et décide de réorienter sa trajectoire. L'accent est désormais mis sur son développement personnel, de nouveaux objectifs et plus d'indépendance.",
|
||||
pt: "DogFather encerra a parceria com sua antiga agência e decide reorientar seu caminho. O foco passa a ser o desenvolvimento pessoal, novas metas e mais independência.",
|
||||
},
|
||||
|
||||
/* ---------- Station 6 — 2. bis 15. Juni 2026 ---------- */
|
||||
zr_ev6_date: {
|
||||
de: "2. bis 15. Juni 2026", "de-CH": "2. bis 15. Juni 2026",
|
||||
@@ -135,21 +139,21 @@ window.I18N_PAGE = {
|
||||
pt: "DogFather assume o cargo de gerente na Spicy Media. Além de atuar como streamer, ele passa a acompanhar criadores, descobrir talentos e construir sua própria equipe forte.",
|
||||
},
|
||||
|
||||
/* ---------- Station 8 — Juli 2026 ---------- */
|
||||
zr_ev8_date: {
|
||||
de: "Juli 2026", "de-CH": "Juli 2026",
|
||||
en: "July 2026", fr: "Juillet 2026", pt: "Julho de 2026",
|
||||
/* ---------- Station 21 — 25. Juli 2026 (Meilenstein, Nutzer-Wunsch 21.08.2026) ---------- */
|
||||
zr_ev21_date: {
|
||||
de: "25. Juli 2026", "de-CH": "25. Juli 2026",
|
||||
en: "July 25, 2026", fr: "25 juillet 2026", pt: "25 de julho de 2026",
|
||||
},
|
||||
zr_ev8_h3: {
|
||||
de: "Das Manager-Team wächst", "de-CH": "Ds Manager-Team wachst",
|
||||
en: "The Manager Team Grows", fr: "L'équipe de management s'agrandit", pt: "A equipe de gerência cresce",
|
||||
zr_ev21_h3: {
|
||||
de: "Erstes Fan-Treffen: Team TiliDog", "de-CH": "Erschts Fan-Träffe: Team TiliDog",
|
||||
en: "First Fan Meetup: Team TiliDog", fr: "Première rencontre fans : Team TiliDog", pt: "Primeiro encontro de fãs: Team TiliDog",
|
||||
},
|
||||
zr_ev8_p: {
|
||||
de: "Mit VanVan als rechter Hand und Diene als wichtiger Unterstützung entsteht eine klare Teamstruktur. Gemeinsam werden Creator begleitet, Probleme gelöst und neue Projekte geplant.",
|
||||
"de-CH": "Mit VanVan als rechti Hand und Diene als wichtigi Unterstützig entstaht e klari Teamstruktur. Zäme wärded Creator begleitet, Problem glöst und neui Projekt planet.",
|
||||
en: "With VanVan as his right hand and Diene as key support, a clear team structure takes shape. Together, they guide creators, solve problems, and plan new projects.",
|
||||
fr: "Avec VanVan comme bras droit et Diene comme soutien essentiel, une structure d'équipe claire se met en place. Ensemble, ils accompagnent les créateurs, résolvent les problèmes et planifient de nouveaux projets.",
|
||||
pt: "Com VanVan como braço direito e Diene como apoio fundamental, surge uma estrutura de equipe clara. Juntos, eles acompanham criadores, resolvem problemas e planejam novos projetos.",
|
||||
zr_ev21_p: {
|
||||
de: "In Wuppertal kommt die Community von Dogi & Tili zum allerersten Mal persönlich zusammen – ein richtig mega Tag voller guter Laune. Es ist gleichzeitig etwas ganz Besonderes: das erste Aufeinandertreffen von DogFather und Tili überhaupt.",
|
||||
"de-CH": "In Wuppertal chunnt d Community vo Dogi & Tili zum allererschte Mal persönlich zäme – en richtig mega Tag voll gueter Luune. Gliichzitig isch es öppis ganz Bsundrigs: s erschte Ufeinanderträffe vo DogFather und Tili überhaupt.",
|
||||
en: "In Wuppertal, the Dogi & Tili community comes together in person for the very first time – a truly awesome day full of good vibes. It's also something very special: the first time DogFather and Tili ever meet face to face.",
|
||||
fr: "À Wuppertal, la communauté de Dogi & Tili se retrouve en personne pour la toute première fois – une journée vraiment géniale et pleine de bonne humeur. C'est aussi un moment très particulier : la toute première rencontre entre DogFather et Tili.",
|
||||
pt: "Em Wuppertal, a comunidade de Dogi & Tili se reúne pessoalmente pela primeiríssima vez – um dia realmente incrível e cheio de boas vibrações. É também algo muito especial: o primeiro encontro cara a cara entre DogFather e Tili.",
|
||||
},
|
||||
|
||||
/* ---------- Station 9 — 27. Juli bis 2. August 2026 (Meilenstein) ---------- */
|
||||
@@ -162,11 +166,11 @@ window.I18N_PAGE = {
|
||||
en: "The Creator Cup", fr: "Le Creator Cup", pt: "O Creator Cup",
|
||||
},
|
||||
zr_ev9_p: {
|
||||
de: "Gemeinsam mit Cidem organisiert DogFather den Creator Cup für die Streamer von Spicy Media. Liveminuten, Teamherzen und Coins werden zu Punkten – verbunden mit besonderen Preisen und einer starken gemeinsamen Aktion.",
|
||||
"de-CH": "Zäme mit Cidem organisiert DogFather de Creator Cup für d Streamer vo Spicy Media. Liveminute, Teamherze und Coins wärded zu Punkt — verbunde mit bsundere Priise und ere starke gmeinsame Aktion.",
|
||||
en: "Together with Cidem, DogFather organizes the Creator Cup for Spicy Media's streamers. Live minutes, team hearts, and coins become points — combined with special prizes and a strong joint event.",
|
||||
fr: "Avec Cidem, DogFather organise le Creator Cup pour les streamers de Spicy Media. Minutes de live, cœurs d'équipe et coins deviennent des points, avec des récompenses spéciales et un fort esprit collectif.",
|
||||
pt: "Junto com Cidem, DogFather organiza o Creator Cup para os streamers da Spicy Media. Minutos ao vivo, corações da equipe e coins viram pontos, com prêmios especiais e uma forte ação em conjunto.",
|
||||
de: "Gemeinsam mit Cidgem organisiert DogFather den Creator Cup für die Streamer von Spicy Media. Liveminuten, Teamherzen und Coins werden zu Punkten – verbunden mit besonderen Preisen und einer starken gemeinsamen Aktion.",
|
||||
"de-CH": "Zäme mit Cidgem organisiert DogFather de Creator Cup für d Streamer vo Spicy Media. Liveminute, Teamherze und Coins wärded zu Punkt — verbunde mit bsundere Priise und ere starke gmeinsame Aktion.",
|
||||
en: "Together with Cidgem, DogFather organizes the Creator Cup for Spicy Media's streamers. Live minutes, team hearts, and coins become points — combined with special prizes and a strong joint event.",
|
||||
fr: "Avec Cidgem, DogFather organise le Creator Cup pour les streamers de Spicy Media. Minutes de live, cœurs d'équipe et coins deviennent des points, avec des récompenses spéciales et un fort esprit collectif.",
|
||||
pt: "Junto com Cidgem, DogFather organiza o Creator Cup para os streamers da Spicy Media. Minutos ao vivo, corações da equipe e coins viram pontos, com prêmios especiais e uma forte ação em conjunto.",
|
||||
},
|
||||
|
||||
/* ---------- Station 10 — 28. Juli 2026 ---------- */
|
||||
@@ -220,7 +224,11 @@ window.I18N_PAGE = {
|
||||
pt: "Começa o planejamento de um site próprio. Ele deve unir pela primeira vez os três grandes universos de DogFather: o streamer DogFather, o gerente na Spicy Media e a marca HasiDog.",
|
||||
},
|
||||
|
||||
/* ---------- Station 13 — Datum folgt ---------- */
|
||||
/* ---------- Station 13 — November 2025 ---------- */
|
||||
zr_ev13_date: {
|
||||
de: "November 2025", "de-CH": "November 2025",
|
||||
en: "November 2025", fr: "Novembre 2025", pt: "Novembro de 2025",
|
||||
},
|
||||
zr_ev13_h3: {
|
||||
de: `Das Buch „HasiDog & Casper"`, "de-CH": `Ds Buech „HasiDog & Casper"`,
|
||||
en: `The Book "HasiDog & Casper"`, fr: `Le livre « HasiDog & Casper »`, pt: `O livro "HasiDog & Casper"`,
|
||||
@@ -233,7 +241,11 @@ window.I18N_PAGE = {
|
||||
pt: "A história especial de DogFather, HasiDog e seu husky Casper se transforma em um projeto de livro próprio. Uma história sobre amizade, família, união e aventuras compartilhadas.",
|
||||
},
|
||||
|
||||
/* ---------- Station 14 — Datum folgt ---------- */
|
||||
/* ---------- Station 14 — 1. Januar 2026 ---------- */
|
||||
zr_ev14_date: {
|
||||
de: "1. Januar 2026", "de-CH": "1. Januar 2026",
|
||||
en: "January 1, 2026", fr: "1er janvier 2026", pt: "1º de janeiro de 2026",
|
||||
},
|
||||
zr_ev14_h3: {
|
||||
de: "HasiDog startet auf Spotify", "de-CH": "HasiDog startet uf Spotify",
|
||||
en: "HasiDog Launches on Spotify", fr: "HasiDog arrive sur Spotify", pt: "HasiDog estreia no Spotify",
|
||||
@@ -259,49 +271,10 @@ window.I18N_PAGE = {
|
||||
pt: "Começa uma colaboração própria de produtos em torno da VanVan. Estão previstos produtos especiais que refletem sua personalidade e seu papel importante dentro da Team Dogi.",
|
||||
},
|
||||
|
||||
/* ---------- Station 16 — Datum folgt ---------- */
|
||||
zr_ev16_h3: {
|
||||
de: "Die ersten betreuten Creator", "de-CH": "Die erste betreute Creator",
|
||||
en: "The First Supported Creators", fr: "Les premiers créateurs accompagnés", pt: "Os primeiros criadores acompanhados",
|
||||
},
|
||||
zr_ev16_p: {
|
||||
de: "Die ersten Creator schließen sich dem Team von Manager DogFather an. Sie erhalten persönliche Unterstützung, Hilfe bei Problemen, Profil-Checks, Schulungen und eine langfristige Begleitung auf TikTok.",
|
||||
"de-CH": "Die erste Creator schliessed sich em Team vom Manager DogFather a. Sie überchömed persönlichi Unterstützig, Hilf bi Problem, Profil-Checks, Schulige und e langfristigi Begleitig uf TikTok.",
|
||||
en: "The first creators join Manager DogFather's team. They receive personal support, help with problems, profile checks, training, and long-term guidance on TikTok.",
|
||||
fr: "Les premiers créateurs rejoignent l'équipe du manager DogFather. Ils bénéficient d'un accompagnement personnalisé, d'aide en cas de problème, de vérifications de profil, de formations et d'un suivi durable sur TikTok.",
|
||||
pt: "Os primeiros criadores se juntam à equipe do gerente DogFather. Eles recebem apoio pessoal, ajuda com problemas, análises de perfil, treinamentos e acompanhamento de longo prazo no TikTok.",
|
||||
},
|
||||
|
||||
/* ---------- Station 17 — Datum folgt ---------- */
|
||||
zr_ev17_h3: {
|
||||
de: "Weitere Scouts und Manager kommen hinzu", "de-CH": "Wiiteri Scouts und Manager chömed dezue",
|
||||
en: "More Scouts and Managers Join", fr: "Plus de scouts et de managers rejoignent l'équipe", pt: "Mais scouts e gerentes se juntam",
|
||||
},
|
||||
zr_ev17_p: {
|
||||
de: "Das Team soll langfristig weiter wachsen. Engagierte Scouts erhalten die Möglichkeit, sich über verschiedene Entwicklungsstufen bis hin zum eigenen Manager weiterzuentwickeln.",
|
||||
"de-CH": "Ds Team söll langfristig wiiter wachse. Engagierti Scouts überchömed d Möglichkeit, sich über verschideni Entwickligsstufe bis zum eigete Manager wiiterzentwickle.",
|
||||
en: "The team is set to keep growing long-term. Dedicated scouts get the chance to develop through various stages all the way up to becoming a manager themselves.",
|
||||
fr: "L'équipe est destinée à continuer de grandir sur le long terme. Les scouts engagés ont la possibilité d'évoluer à travers différents niveaux, jusqu'à devenir eux-mêmes managers.",
|
||||
pt: "A equipe deve continuar crescendo a longo prazo. Scouts dedicados ganham a chance de evoluir por diferentes níveis até se tornarem gerentes.",
|
||||
},
|
||||
|
||||
/* ---------- Station 18 — Datum folgt ---------- */
|
||||
zr_ev18_h3: {
|
||||
de: "Das erste Community-Treffen", "de-CH": "Ds erste Community-Träffe",
|
||||
en: "The First Community Meetup", fr: "La première rencontre communautaire", pt: "O primeiro encontro da comunidade",
|
||||
},
|
||||
zr_ev18_p: {
|
||||
de: "Team Dogi begegnet sich nicht mehr nur im Livestream oder auf Discord. Beim gemeinsamen Treffen entstehen echte Erinnerungen, die zeigen, dass diese Community weit mehr als nur Zahlen und Likes ist.",
|
||||
"de-CH": "Team Dogi begägnet sich nid nur im Livestream oder uf Discord. Bim gmeinsame Träffe entstönd echti Erinnerige, wo zeiged, dass die Community wit meh isch als nume Zahle und Likes.",
|
||||
en: "Team Dogi no longer just meets in livestreams or on Discord. At the shared meetup, real memories are made — proof that this community is far more than just numbers and likes.",
|
||||
fr: "Team Dogi ne se retrouve plus seulement en live ou sur Discord. Lors de la rencontre commune naissent de vrais souvenirs, preuve que cette communauté est bien plus que des chiffres et des likes.",
|
||||
pt: "A Team Dogi não se encontra mais só em lives ou no Discord. No encontro presencial nascem memórias reais, mostrando que essa comunidade é muito mais do que números e curtidas.",
|
||||
},
|
||||
|
||||
/* ---------- Station 19 — 2026 – Datum folgt (Meilenstein) ---------- */
|
||||
/* ---------- Station 19 — 21. August 2026, 21 Uhr (Meilenstein) ---------- */
|
||||
zr_ev19_date: {
|
||||
de: "2026 – Datum folgt", "de-CH": "2026 – Datum chunnt no",
|
||||
en: "2026 – date coming soon", fr: "2026 – date à venir", pt: "2026 – data em breve",
|
||||
de: "21. August 2026, 21 Uhr", "de-CH": "21. Auguscht 2026, 21 Uhr",
|
||||
en: "August 21, 2026, 9 PM", fr: "21 août 2026, 21 h", pt: "21 de agosto de 2026, 21h",
|
||||
},
|
||||
zr_ev19_h3: {
|
||||
de: "DOGFATHER UNIVERSE geht online", "de-CH": "DOGFATHER UNIVERSE gaht online",
|
||||
|
||||
@@ -76,15 +76,22 @@
|
||||
|
||||
const I18N_CORE = {
|
||||
nav_start: { de: "Start", "de-CH": "Start", en: "Home", fr: "Accueil", pt: "Início" },
|
||||
nav_dogfather: { de: "DogFather", "de-CH": "DogFather", en: "DogFather", fr: "DogFather", pt: "DogFather" },
|
||||
/* Umbenennungen 20.08.2026 (Nutzer-Wunsch): Kategorie "DogFather" -> "Filipe",
|
||||
Kategorie "HasiDog" -> "Dogi&Hasi". Die Schlüsselnamen (nav_dogfather...)
|
||||
bleiben bewusst unverändert — sie sind reine interne Bezeichner und werden
|
||||
an mehreren Stellen (Nav, Fußzeile, Farbzuordnung) referenziert; ein
|
||||
Umbenennen würde nur Fehlerquellen schaffen, ohne sichtbaren Nutzen. */
|
||||
nav_dogfather: { de: "Filipe", "de-CH": "Filipe", en: "Filipe", fr: "Filipe", pt: "Filipe" },
|
||||
nav_dogfather_person: { de: "Die Person", "de-CH": "D Person", en: "The Person", fr: "La personne", pt: "A pessoa" },
|
||||
nav_dogfather_streamer: { de: "Streamer", "de-CH": "Streamer", en: "Streamer", fr: "Streamer", pt: "Streamer" },
|
||||
/* Sitzt seit 20.08.2026 unter "Dogi&Hasi" (nicht mehr unter "Filipe") und heißt
|
||||
dort "DogFather" statt "Streamer" — zeigt weiterhin auf streamer.html. */
|
||||
nav_dogfather_streamer: { de: "DogFather", "de-CH": "DogFather", en: "DogFather", fr: "DogFather", pt: "DogFather" },
|
||||
nav_dogfather_casper: { de: "Casper", "de-CH": "Casper", en: "Casper", fr: "Casper", pt: "Casper" },
|
||||
nav_dogfather_vanvan: { de: "VanVan", "de-CH": "VanVan", en: "VanVan", fr: "VanVan", pt: "VanVan" },
|
||||
nav_dogfather_zeitreise: { de: "Zeitreise", "de-CH": "Zytreis", en: "Timeline", fr: "Chronologie", pt: "Linha do tempo" },
|
||||
nav_dogfather_ttmuddi: { de: "TT-Muddi", "de-CH": "TT-Muddi", en: "TT-Muddi", fr: "TT-Muddi", pt: "TT-Muddi" },
|
||||
nav_hasidog: { de: "HasiDog", "de-CH": "HasiDog", en: "HasiDog", fr: "HasiDog", pt: "HasiDog" },
|
||||
nav_hasidog_welt: { de: "HasiDog-Welt", "de-CH": "HasiDog-Wält", en: "HasiDog World", fr: "Monde HasiDog", pt: "Mundo HasiDog" },
|
||||
nav_hasidog: { de: "Dogi&Hasi", "de-CH": "Dogi&Hasi", en: "Dogi&Hasi", fr: "Dogi&Hasi", pt: "Dogi&Hasi" },
|
||||
nav_hasidog_welt: { de: "HasiDog", "de-CH": "HasiDog", en: "HasiDog", fr: "HasiDog", pt: "HasiDog" },
|
||||
nav_hasidog_modi: { de: "Modi-Team", "de-CH": "Modi-Team", en: "Modi-Team", fr: "Modi-Team", pt: "Modi-Team" },
|
||||
nav_hasidog_community: { de: "Community", "de-CH": "Community", en: "Community", fr: "Communauté", pt: "Comunidade" },
|
||||
nav_hasidog_stimmen: { de: "Stimmen", "de-CH": "Stimme", en: "Voices", fr: "Témoignages", pt: "Depoimentos" },
|
||||
@@ -104,6 +111,11 @@
|
||||
nav_cta_abo_anonym: { de: "DogiCrew 👑", "de-CH": "DogiCrew 👑", en: "DogiCrew 👑", fr: "DogiCrew 👑", pt: "DogiCrew 👑" },
|
||||
nav_cta_abo_aktivieren: { de: "Abo aktivieren 👑", "de-CH": "Abo aktiviere 👑", en: "Activate Subscription 👑", fr: "Activer l'abonnement 👑", pt: "Ativar assinatura 👑" },
|
||||
nav_cta_abo_bereich: { de: "Supporter-Bereich 👑", "de-CH": "Supporter-Bereich 👑", en: "Supporter Area 👑", fr: "Espace Supporter 👑", pt: "Área do Apoiador 👑" },
|
||||
/* Kleines Schimmer-Badge auf dem Abo-Knopf, solange die Registrierung
|
||||
noch gesperrt ist (20.08.2026, Nutzer-Wunsch) — bewusst ein kurzes
|
||||
Wort statt "Coming soon…", weil in der schmalen Nav-Pille (siehe
|
||||
Mobil-Menü-Fix vom selben Tag) kaum Platz ist. */
|
||||
nav_cta_abo_soon: { de: "Bald", "de-CH": "Bald", en: "Soon", fr: "Bientôt", pt: "Em breve" },
|
||||
nav_toggle_aria: { de: "Menü öffnen", "de-CH": "Menü uftue", en: "Open menu", fr: "Ouvrir le menu", pt: "Abrir menu" },
|
||||
sprache_aria: { de: "Sprache wählen", "de-CH": "Sprach uswähle", en: "Choose language", fr: "Choisir la langue", pt: "Escolher idioma" },
|
||||
brand_tagline: {
|
||||
@@ -120,7 +132,9 @@
|
||||
fr: "Streamer. Communauté. HasiDog. Manager. Plus forts ensemble.",
|
||||
pt: "Streamer. Comunidade. HasiDog. Manager. Mais fortes juntos.",
|
||||
},
|
||||
footer_h4_hasidog_manager: { de: "HasiDog & Manager", "de-CH": "HasiDog & Manager", en: "HasiDog & Manager", fr: "HasiDog et Manager", pt: "HasiDog e Manager" },
|
||||
/* Fußzeilen-Überschrift zieht mit der Kategorie-Umbenennung mit (20.08.2026):
|
||||
"HasiDog & Manager" -> "Dogi&Hasi & Manager". */
|
||||
footer_h4_hasidog_manager: { de: "Dogi&Hasi & Manager", "de-CH": "Dogi&Hasi & Manager", en: "Dogi&Hasi & Manager", fr: "Dogi&Hasi et Manager", pt: "Dogi&Hasi e Manager" },
|
||||
footer_h4_mehr: { de: "Mehr", "de-CH": "Meh", en: "More", fr: "Plus", pt: "Mais" },
|
||||
footer_scouts: { de: "Scouts", "de-CH": "Scouts", en: "Scouts", fr: "Scouts", pt: "Scouts" },
|
||||
footer_alle_links: { de: "Alle Links", "de-CH": "Alli Links", en: "All links", fr: "Tous les liens", pt: "Todos os links" },
|
||||
@@ -133,6 +147,7 @@
|
||||
pt: "© {jahr} DogFather Universe. Team Dogi é família 🩵",
|
||||
},
|
||||
footer_impressum: { de: "Impressum & Datenschutz", "de-CH": "Impressum & Datenschutz", en: "Legal notice & privacy", fr: "Mentions légales et confidentialité", pt: "Aviso legal e privacidade" },
|
||||
footer_app_installieren: { de: "Als App installieren", "de-CH": "Als App installiere", en: "Install as app", fr: "Installer comme application", pt: "Instalar como aplicativo" },
|
||||
music_title: { de: "Musik von Team Dogi 🎵", "de-CH": "Musig vo Team Dogi 🎵", en: "Music from Team Dogi 🎵", fr: "Musique de Team Dogi 🎵", pt: "Música do Team Dogi 🎵" },
|
||||
music_close_aria: { de: "Musik-Feld schließen", "de-CH": "Musig-Feld zuemache", en: "Close music panel", fr: "Fermer le panneau musique", pt: "Fechar painel de música" },
|
||||
music_intro: {
|
||||
@@ -184,6 +199,22 @@
|
||||
if (text) el.setAttribute(attr, text);
|
||||
});
|
||||
});
|
||||
|
||||
// 21.08.2026 (Nutzer-Wunsch: "ich will ich titel und namen von
|
||||
// kategorien und seiten ändern können in der verwaltung"): admin-
|
||||
// editierbare Text-Overrides (siehe window.dogiSiteTexteLaden() weiter
|
||||
// unten). Läuft bewusst NACH den normalen data-i18n-Standardtexten
|
||||
// oben, damit ein gesetzter Override sie überschreiben kann. Fehlt ein
|
||||
// Override (Normalfall, noch nichts in der Verwaltung eingetragen),
|
||||
// bleibt einfach der eingebaute Standardtext stehen -- diese Funktion
|
||||
// hier macht dann schlicht nichts.
|
||||
const siteTexte = window.__siteTexteCache || {};
|
||||
document.querySelectorAll("[data-site-text-key]").forEach((el) => {
|
||||
const eintrag = siteTexte[el.getAttribute("data-site-text-key")];
|
||||
if (!eintrag) return;
|
||||
const text = eintrag[sprache] || eintrag.de;
|
||||
if (text) el.innerHTML = text;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Navigationsstruktur (an einer Stelle pflegen) ---------- */
|
||||
@@ -193,7 +224,6 @@
|
||||
key: "nav_dogfather",
|
||||
children: [
|
||||
{ key: "nav_dogfather_person", href: "dogfather.html" },
|
||||
{ key: "nav_dogfather_streamer", href: "streamer.html" },
|
||||
{ key: "nav_dogfather_casper", href: "casper.html" },
|
||||
{ key: "nav_dogfather_vanvan", href: "vanvan.html" },
|
||||
{ key: "nav_dogfather_ttmuddi", href: "tt-muddi.html" },
|
||||
@@ -203,6 +233,10 @@
|
||||
{
|
||||
key: "nav_hasidog",
|
||||
children: [
|
||||
/* "DogFather" (streamer.html) hierher verschoben — Nutzer-Wunsch
|
||||
20.08.2026: gehört unter "Dogi&Hasi" und dort GANZ NACH OBEN,
|
||||
direkt über "HasiDog". */
|
||||
{ key: "nav_dogfather_streamer", href: "streamer.html" },
|
||||
{ key: "nav_hasidog_welt", href: "hasidog.html" },
|
||||
{ key: "nav_hasidog_modi", href: "team-modis.html" },
|
||||
{ key: "nav_hasidog_community", href: "community.html" },
|
||||
@@ -454,6 +488,17 @@
|
||||
return `${escapeHtml(kuerzen(sauber, 18))} 👑`;
|
||||
}
|
||||
|
||||
/* Kleines Schimmer-Badge "Bald", solange die eigentliche Registrierung auf
|
||||
abonnieren.html gesperrt ist (20.08.2026, siehe .btn-gold-locked dort).
|
||||
Nur auf den drei Zielen, die dort ankommen — NICHT auf supporter.html
|
||||
(echter, bereits funktionierender Bereich für Dogi/VanVan als Test-
|
||||
Supporter), sonst würde es fälschlich so wirken, als sei der ganze
|
||||
Supporter-Bereich gesperrt. Reine Wiederverwendung von .btn-silver-text/
|
||||
-spark (siehe main.css) in kleinerem Maßstab, kein neuer Mechanismus. */
|
||||
function soonBadge() {
|
||||
return `<span class="nav-cta-soon"><span class="btn-silver-spark" aria-hidden="true">✨</span><span class="btn-silver-text">${t("nav_cta_abo_soon")}</span></span>`;
|
||||
}
|
||||
|
||||
function renderAboButton() {
|
||||
const vorschau = getVorschauModus();
|
||||
if (vorschau === "abonnent") {
|
||||
@@ -469,20 +514,20 @@
|
||||
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(echterName || vorschauAnzeigename())}</span></a>`;
|
||||
}
|
||||
if (vorschau === "nicht-abonnent") {
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span></a>`;
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span>${soonBadge()}</a>`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("supporterToken");
|
||||
const status = localStorage.getItem("supporterSubStatus");
|
||||
if (!token) {
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span></a>`;
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span>${soonBadge()}</a>`;
|
||||
}
|
||||
const hatBereich = ["active", "pending", "suspended", "cancelled"].includes(status);
|
||||
if (hatBereich) {
|
||||
const name = localStorage.getItem("supporterDisplayName");
|
||||
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(name)}</span></a>`;
|
||||
}
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_aktivieren")}</span></a>`;
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_aktivieren")}</span>${soonBadge()}</a>`;
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
@@ -524,6 +569,24 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report per Screenshot): ein per Klick
|
||||
geöffnetes Kategorie-Menü blieb offen stehen, bis irgendwo anders
|
||||
hingeklickt wurde — verlässt man es einfach nur mit der Maus (ohne
|
||||
zu klicken), sollte es sich von selbst schließen, genau wie das
|
||||
reine Hover-Aufklappen (siehe main.css @media (hover:hover)) es
|
||||
nahelegt. Nur auf echten Maus-Geräten (nicht Touch/Tablet, dort
|
||||
gibt es kein "Verlassen mit der Maus", nur echte Klicks). */
|
||||
const hatEchteMaus = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
if (hatEchteMaus) {
|
||||
el.querySelectorAll(".nav-group").forEach((group) => {
|
||||
group.addEventListener("mouseleave", () => {
|
||||
if (!group.classList.contains("open")) return;
|
||||
group.classList.remove("open");
|
||||
group.querySelector("button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
el.querySelectorAll(".sprach-schalter [data-sprache]").forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -554,12 +617,12 @@
|
||||
<p class="small" style="margin-top:.8rem;max-width:32ch;">
|
||||
${t("footer_tagline")}
|
||||
</p>
|
||||
<button type="button" id="pwa-install-btn" class="btn btn-outline" style="display:none;margin-top:1rem;">📲 ${t("footer_app_installieren")}</button>
|
||||
</div>
|
||||
<div>
|
||||
<h4>DogFather</h4>
|
||||
<h4>${t("nav_dogfather")}</h4>
|
||||
<ul>
|
||||
<li><a href="dogfather.html">${t("nav_dogfather_person")}</a></li>
|
||||
<li><a href="streamer.html">${t("nav_dogfather_streamer")}</a></li>
|
||||
<li><a href="casper.html">${t("nav_dogfather_casper")}</a></li>
|
||||
<li><a href="vanvan.html">${t("nav_dogfather_vanvan")}</a></li>
|
||||
<li><a href="tt-muddi.html">${t("nav_dogfather_ttmuddi")}</a></li>
|
||||
@@ -569,6 +632,10 @@
|
||||
<div>
|
||||
<h4>${t("footer_h4_hasidog_manager")}</h4>
|
||||
<ul>
|
||||
<!-- "DogFather"/streamer.html steht seit 20.08.2026 auch in der
|
||||
Fußzeile in dieser Spalte (und ganz oben), damit sie dieselbe
|
||||
Struktur zeigt wie die Navigation darüber. -->
|
||||
<li><a href="streamer.html">${t("nav_dogfather_streamer")}</a></li>
|
||||
<li><a href="hasidog.html">${t("nav_hasidog_welt")}</a></li>
|
||||
<li><a href="team-modis.html">${t("nav_hasidog_modi")}</a></li>
|
||||
<li><a href="community.html">${t("nav_hasidog_community")}</a></li>
|
||||
@@ -874,7 +941,22 @@
|
||||
// erneut laufen, sonst bleibt window.I18N_PAGE nach einem
|
||||
// Zwischenstopp auf einer anderen Seite (die es zurückgesetzt hat)
|
||||
// fälschlich leer, wenn man z.B. zur Startseite zurückkehrt.
|
||||
const istSprachDatei = /\/i18n-[^/]+\.js$/.test(src);
|
||||
//
|
||||
// BUGFIX 21.08.2026 (Nutzer-Report: rohe Übersetzungs-Schlüssel wie
|
||||
// "ts_profil_ansehen" sichtbar, aber NUR nachdem man auf einen Link
|
||||
// geklickt und dann "Zurück" gedrückt hat): das Muster endete bisher
|
||||
// auf ".js$" (striktes String-Ende). Seit die Cache-Busting-Version
|
||||
// ("?v=20260821...") an JEDE Skript-URL angehängt wird, endet der
|
||||
// echte src-Wert aber nie mehr auf ".js", sondern auf ".js?v=...".
|
||||
// Der Test schlug dadurch für JEDE i18n-Datei fehl -> istSprachDatei
|
||||
// war immer false -> die i18n-Datei wurde wie eine normale, schon
|
||||
// geladene Datei übersprungen und NIE erneut ausgeführt, sobald man
|
||||
// per Klick auf eine andere Seite und wieder zurück navigiert ist.
|
||||
// window.I18N_PAGE blieb dadurch dauerhaft auf null (von der
|
||||
// Zwischenseite zurückgesetzt) stehen -- jede Übersetzung fiel auf
|
||||
// den rohen Schlüsselnamen zurück. Fix: ".js" darf jetzt auch von
|
||||
// einem "?..." gefolgt werden, nicht nur vom exakten String-Ende.
|
||||
const istSprachDatei = /\/i18n-[^/]+\.js(\?.*)?$/.test(src);
|
||||
|
||||
// Schon vorhanden (z.B. main.js oder eine bereits geladene Datendatei):
|
||||
// nicht erneut ausführen — sonst gäbe es doppelte Deklarationen.
|
||||
@@ -986,6 +1068,7 @@
|
||||
|
||||
await skripteUebernehmen(dok);
|
||||
applyTranslations(); // seiteneigene data-i18n-Elemente der neuen Seite übersetzen
|
||||
if (window.dogiSiteTexteLaden) window.dogiSiteTexteLaden(); // Kategorie-/Seitentitel-Overrides der neuen Seite
|
||||
}
|
||||
|
||||
function nahtloseNavigationAktivieren() {
|
||||
@@ -1039,10 +1122,25 @@
|
||||
renderFooter();
|
||||
renderFog();
|
||||
renderMusicWidget();
|
||||
renderVorschauSchalter();
|
||||
// Bugfix/Nutzer-Wunsch 20.08.2026, vor dem morgigen öffentlichen Start:
|
||||
// "die leute sollen das ja nicht selbst auswählen können". Der Schalter
|
||||
// erkennt Dogi/VanVan bisher nur über das rein client-seitig lesbare
|
||||
// Cookie dogi_role (bewusst NICHT das echte, HttpOnly-geschützte
|
||||
// Sicherheits-Cookie, siehe Kommentar bei getVorschauRolle() oben) —
|
||||
// technisch könnte sich also jede Besucherin/jeder Besucher dieses
|
||||
// Cookie selbst per Browser-Konsole setzen und den Schalter auslösen.
|
||||
// Echte Daten wären dadurch nie einsehbar (der Schalter zeigt nur
|
||||
// Beispieldaten für den eigenen Browser), aber "soll weg für die
|
||||
// Öffentlichkeit" heißt hier bewusst: ganz weg, nicht nur "sicherer".
|
||||
// renderVorschauSchalter() bleibt unangetastet erhalten (funktioniert
|
||||
// exakt wie vorher), nur DIESER Aufruf beim Seitenaufbau ist deaktiviert
|
||||
// — für ein künftiges internes Testen kann diese Zeile jederzeit wieder
|
||||
// aktiviert werden.
|
||||
// renderVorschauSchalter();
|
||||
musikKnopfFarbenAnpassen();
|
||||
nahtloseNavigationAktivieren();
|
||||
applyTranslations(); // seiteneigene data-i18n-Elemente (z.B. Startseite) übersetzen
|
||||
if (window.dogiSiteTexteLaden) window.dogiSiteTexteLaden(); // Kategorie-/Seitentitel-Overrides (siehe Verwaltung)
|
||||
});
|
||||
|
||||
/* Kleine öffentliche Schnittstelle für seiteneigene Inline-Skripte
|
||||
@@ -1074,6 +1172,99 @@
|
||||
return feld[sprache] || feld.de || "";
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
dogiTeamLaden() — 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."
|
||||
|
||||
Holt die über die Verwaltung angelegten Team-Mitglieder einer
|
||||
Kategorie ("modi" | "scout" | "creator" | "manager") vom Server und
|
||||
bringt sie in GENAU dieselbe Objektform wie die von Hand gepflegten
|
||||
Einträge in data-modis.js/data-scouts.js/data-creator.js/
|
||||
data-manager.js (name/slug/role/photo/bio/quote/social/...) — die
|
||||
aufrufende Seite hängt das Ergebnis einfach hinten an ihr bestehendes
|
||||
window.MODIS/SCOUTS/CREATORS/MANAGERS-Array an und ruft ihre eigene,
|
||||
unveränderte Render-Funktion erneut auf. Kein kategorie-Parameter
|
||||
liefert ALLE Kategorien zusammen (genutzt von profil.html, das vorab
|
||||
nicht weiß, zu welcher Kategorie ein slug gehört).
|
||||
|
||||
Schlägt die Anfrage fehl (Server nicht erreichbar, Netz weg): leeres
|
||||
Array statt Fehler -- die von Hand gepflegten Einträge bleiben davon
|
||||
komplett unberührt sichtbar, nur admin-gepflegte kommen dann halt
|
||||
(vorübergehend) nicht hinzu. */
|
||||
const TEAM_API_BASIS = "https://postfach.dogfather-universe.com";
|
||||
window.dogiTeamLaden = async function (kategorie) {
|
||||
try {
|
||||
const url = kategorie ? `${TEAM_API_BASIS}/team?kategorie=${encodeURIComponent(kategorie)}` : `${TEAM_API_BASIS}/team`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!data || !data.ok || !Array.isArray(data.mitglieder)) return [];
|
||||
return data.mitglieder.map((m) => ({
|
||||
name: m.name,
|
||||
slug: m.slug,
|
||||
kategorie: m.kategorie,
|
||||
role: m.rolle || "",
|
||||
photo: m.foto || "",
|
||||
avatar: m.foto || "",
|
||||
// 21.08.2026 (Runde 2, "wenn ich eine änderung mache ist es auch
|
||||
// für jeden"): volle Feld-Parität mit den von Hand gepflegten
|
||||
// Profilen, damit ein importiertes/bearbeitetes Profil genau so
|
||||
// reichhaltig sein kann wie die schon bestehenden.
|
||||
intro: m.intro || "",
|
||||
bio: m.bio || "",
|
||||
bioHtml: m.bioHtml || "",
|
||||
quote: m.zitat || "",
|
||||
social: m.tiktok ? { tiktok: m.tiktok } : {},
|
||||
status: "aktiv", // /team liefert ohnehin nur aktive Mitglieder
|
||||
rank: m.rang || undefined,
|
||||
rankLabel: m.rangLabel || undefined,
|
||||
extraCta: m.extraCta || undefined,
|
||||
// Markiert admin-gepflegte Einträge (z.B. für profil.html, das
|
||||
// sonst nicht zwischen fest einprogrammierten und dynamischen
|
||||
// Personen unterscheiden könnte).
|
||||
istAdminGepflegt: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/* 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"): fügt eine
|
||||
per dogiTeamLaden() geladene Liste NICHT einfach hinten an die von Hand
|
||||
gepflegte Liste an, sondern ÜBERSCHREIBT darin jeden Eintrag mit
|
||||
demselben slug. So zeigt die Seite nach einem Import + einer
|
||||
Bearbeitung in der Verwaltung automatisch den aktuellen (DB-)Stand
|
||||
statt der alten, einprogrammierten Version -- ist das Backend mal
|
||||
nicht erreichbar, bleibt wenigstens die statische Version sichtbar
|
||||
(nichts verschwindet einfach). */
|
||||
window.dogiTeamZusammenfuehren = function (statische, neue) {
|
||||
if (!neue || !neue.length) return statische;
|
||||
const neueSlugs = new Set(neue.map((n) => n.slug));
|
||||
return [...statische.filter((s) => !neueSlugs.has(s.slug)), ...neue];
|
||||
};
|
||||
|
||||
/* 21.08.2026 (Nutzer-Wunsch: "ich will ich titel und namen von
|
||||
kategorien und seiten ändern können in der verwaltung"): lädt einmalig
|
||||
alle in der Verwaltung gesetzten Text-Overrides (siehe
|
||||
server-internal/routes/site-texts.js) und ruft applyTranslations()
|
||||
danach erneut auf, damit gesetzte Overrides die eingebauten
|
||||
Standardtexte sofort ersetzen. Ohne Overrides (Normalfall) ändert sich
|
||||
einfach nichts -- die Seite zeigt weiterhin ihren gewohnten Text. */
|
||||
window.dogiSiteTexteLaden = async function () {
|
||||
try {
|
||||
const res = await fetch(`${TEAM_API_BASIS}/site-texts`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (data && data.ok) {
|
||||
window.__siteTexteCache = data.texte || {};
|
||||
applyTranslations();
|
||||
}
|
||||
} catch {
|
||||
/* kein Override verfügbar -> eingebaute Standardtexte bleiben stehen */
|
||||
}
|
||||
};
|
||||
|
||||
/* ===================================================================
|
||||
PWA: Service-Worker-Registrierung
|
||||
Voraussetzung dafür, dass Chrome/Edge auf dem Desktop "App
|
||||
@@ -1115,4 +1306,47 @@
|
||||
appleTitel.content = "DogFather";
|
||||
document.head.appendChild(appleTitel);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
PWA: sichtbarer "App installieren"-Knopf (20.08.2026)
|
||||
Nutzer-Wunsch: "ich kann sie immer noch nicht runter laden" — reine
|
||||
Installierbarkeit über Manifest+Service-Worker reicht nicht, wenn
|
||||
niemand weiß, WO im Browser man das anklickt (verstecktes
|
||||
Adressleisten-Symbol/3-Punkte-Menü). Jede Seite mit einem Element
|
||||
#pwa-install-btn (siehe renderFooter() oben + verwaltung.html) bekommt
|
||||
jetzt einen echten, sichtbaren Knopf, der den nativen Install-Dialog
|
||||
direkt auslöst — funktioniert pro Seite mit GENAU dem Manifest, das
|
||||
diese Seite selbst verlinkt (index.html -> manifest.json,
|
||||
verwaltung.html -> manifest-verwaltung.json), ganz ohne Sonderfall-Code
|
||||
hier. Zeigt sich NUR, wenn der Browser das überhaupt unterstützt UND
|
||||
die Seite nicht schon als installierte App läuft (Safari/iOS feuert
|
||||
"beforeinstallprompt" nie — dort bleibt "Zum Home-Bildschirm" über das
|
||||
Teilen-Menü weiterhin der einzige Weg, das ist eine Apple-Plattform-
|
||||
Grenze, kein Bug dieser Seite). */
|
||||
let installEvent = null;
|
||||
const laeuftSchonAlsApp = () =>
|
||||
window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
installEvent = e;
|
||||
const btn = document.getElementById("pwa-install-btn");
|
||||
if (btn && !laeuftSchonAlsApp()) btn.style.display = "";
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest && e.target.closest("#pwa-install-btn") && installEvent) {
|
||||
installEvent.prompt();
|
||||
installEvent.userChoice.finally(() => {
|
||||
installEvent = null;
|
||||
const btn = document.getElementById("pwa-install-btn");
|
||||
if (btn) btn.style.display = "none";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
const btn = document.getElementById("pwa-install-btn");
|
||||
if (btn) btn.style.display = "none";
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =====================================================================
|
||||
stimmen.js — NUR für stimmen.html (20.08.2026, Nutzer-Wunsch: "eine
|
||||
richtig geile kachel ... wo die leute mit namen und tiktok namen mir
|
||||
eine nachricht schreiben können die ich in die verwaltung kriege").
|
||||
Formular schickt eine Einreichung ab (landet als Entwurf, siehe
|
||||
server-internal/routes/testimonials.js), zweiter Teil lädt die bereits
|
||||
freigegebenen Stimmen und zeigt sie an — bleibt komplett unsichtbar,
|
||||
solange keine einzige freigegeben wurde. ===================================================================== */
|
||||
(function () {
|
||||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||||
|
||||
const formular = document.getElementById("stimmen-formular");
|
||||
if (formular) {
|
||||
const erfolgEl = document.getElementById("stimmen-erfolg");
|
||||
const fehlerEl = document.getElementById("stimmen-fehler");
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
erfolgEl.classList.remove("zeige");
|
||||
fehlerEl.classList.remove("zeige");
|
||||
|
||||
const name = document.getElementById("st-name").value.trim();
|
||||
const tiktokUsername = document.getElementById("st-tiktok").value.trim();
|
||||
const nachricht = document.getElementById("st-nachricht").value.trim();
|
||||
|
||||
const absendenBtn = formular.querySelector("button[type=submit]");
|
||||
absendenBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/testimonials/submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, tiktokUsername, nachricht }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
fehlerEl.textContent = data.error || "Einreichen hat leider nicht geklappt. Bitte versuch es nochmal.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
return;
|
||||
}
|
||||
formular.hidden = true;
|
||||
erfolgEl.classList.add("zeige");
|
||||
} catch {
|
||||
fehlerEl.textContent = "Verbindung zum Server fehlgeschlagen. Bitte versuch es nochmal.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
} finally {
|
||||
absendenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
||||
}
|
||||
|
||||
/* Erstes sichtbares Zeichen des Namens fürs Avatar-Medaillon. [...name]
|
||||
statt name[0], damit auch Namen, die mit einem Emoji beginnen, nicht
|
||||
mitten in einem Zeichen abgeschnitten werden (ein Emoji besteht in
|
||||
JavaScript aus mehreren "Zeichen" — name[0] lieferte sonst nur ein
|
||||
kaputtes Halb-Zeichen). */
|
||||
function erstesZeichen(name) {
|
||||
const zeichen = [...String(name || "").trim()];
|
||||
return zeichen.length ? zeichen[0].toUpperCase() : "?";
|
||||
}
|
||||
|
||||
function ladeEchteStimmen() {
|
||||
fetch(`${API_BASE_URL}/testimonials`, { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const liste = data && data.ok ? data.testimonials : [];
|
||||
if (!liste.length) return; // Abschnitt bleibt hidden, kein Platzhalter mehr
|
||||
const root = document.getElementById("stimmen-echte-root");
|
||||
const grid = document.getElementById("stimmen-echte-grid");
|
||||
grid.innerHTML = liste
|
||||
.map(
|
||||
(t) => `<div class="stimmen-karte">
|
||||
<p class="stimmen-karte-text">${escapeHtml(t.nachricht)}</p>
|
||||
<div class="stimmen-karte-fuss">
|
||||
<span class="stimmen-karte-avatar" aria-hidden="true">${escapeHtml(erstesZeichen(t.name))}</span>
|
||||
<span class="stimmen-karte-namen">
|
||||
<span class="stimmen-karte-name">${escapeHtml(t.name)}</span>
|
||||
${t.tiktokUsername ? `<span class="stimmen-karte-tiktok">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
root.hidden = false;
|
||||
})
|
||||
.catch(() => {
|
||||
/* Abschnitt bleibt einfach unsichtbar -- kein Fehler nötig. */
|
||||
});
|
||||
}
|
||||
ladeEchteStimmen();
|
||||
})();
|
||||
@@ -0,0 +1,230 @@
|
||||
/* =====================================================================
|
||||
streamplan.js — "Live-Radar" NUR für streamplan.html (20.08.2026,
|
||||
Nutzer-Wunsch: "soll viel geiler und spezieller sein, überrasch mich",
|
||||
ergänzt um eine zweite Uhr für ein "spezielles Live-Event": "sehr
|
||||
wichtig ist dass ich auch spezielle live events da auch eintragen kann
|
||||
in der verwaltungsseite so dass da eine zweite uhr erscheint wo die
|
||||
zeit dann für dieses spezielle event läuft").
|
||||
|
||||
Zwei 24-Stunden-Ziffernblätter (reines SVG, keine Bild-Assets nötig):
|
||||
- Uhr 1 (immer sichtbar): der feste 20-Uhr-Termin leuchtet dauerhaft als
|
||||
Fixpunkt, ein "Jetzt"-Zeiger bewegt sich mit der echten Uhrzeit der
|
||||
besuchenden Person mit. Live laut /live-status -> Rot/Puls +
|
||||
"Jetzt anschauen".
|
||||
- Uhr 2 (nur wenn in der Verwaltung aktiviert): zählt zu einem exakten
|
||||
Zieldatum+Uhrzeit runter (Tage möglich, nicht nur Stunden), Magenta/
|
||||
Violett statt Cyan/Gold, damit beide Uhren auf einen Blick
|
||||
unterscheidbar sind ("bissl Farbe"). ===================================================================== */
|
||||
(function () {
|
||||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const CX = 150, CY = 150, R_OUT = 130, R_TICK_IN = 116, R_TICK_MAJOR_IN = 106, R_HAND = 100;
|
||||
|
||||
function polar(r, stundenAnteil) {
|
||||
const winkel = (stundenAnteil / 24) * 360 - 90; // 0 Uhr oben, im Uhrzeigersinn
|
||||
const rad = (winkel * Math.PI) / 180;
|
||||
return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
/** Zeichnet die 24 Stundenstriche + einen Fixpunkt-Leuchtpunkt bei
|
||||
"fixStunde" (kann eine Dezimalzahl sein, z.B. 19.5 = 19:30) in die
|
||||
übergebenen <g>-Container. Läuft einmalig pro Uhr. */
|
||||
function zeichneZiffernblatt(ticksHost, fixHost, fixStunde) {
|
||||
ticksHost.innerHTML = "";
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const major = h % 6 === 0;
|
||||
const p1 = polar(R_OUT, h);
|
||||
const p2 = polar(major ? R_TICK_MAJOR_IN : R_TICK_IN, h);
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", p1.x); line.setAttribute("y1", p1.y);
|
||||
line.setAttribute("x2", p2.x); line.setAttribute("y2", p2.y);
|
||||
line.setAttribute("class", major ? "radar-tick radar-tick-major" : "radar-tick");
|
||||
ticksHost.appendChild(line);
|
||||
}
|
||||
fixHost.innerHTML = "";
|
||||
if (fixStunde != null) {
|
||||
const fixPos = polar(R_OUT + 11, fixStunde);
|
||||
const fixDot = document.createElementNS(SVG_NS, "circle");
|
||||
fixDot.setAttribute("cx", fixPos.x); fixDot.setAttribute("cy", fixPos.y);
|
||||
fixDot.setAttribute("r", 6.5);
|
||||
fixDot.setAttribute("class", "radar-fixmarker-dot");
|
||||
fixHost.appendChild(fixDot);
|
||||
}
|
||||
}
|
||||
|
||||
function zeigerAktualisieren(handEl, dotEl, stundenAnteil) {
|
||||
const spitze = polar(R_HAND, stundenAnteil);
|
||||
handEl.setAttribute("x2", spitze.x); handEl.setAttribute("y2", spitze.y);
|
||||
dotEl.setAttribute("cx", spitze.x); dotEl.setAttribute("cy", spitze.y);
|
||||
}
|
||||
|
||||
function zweistellig(n) { return String(n).padStart(2, "0"); }
|
||||
|
||||
/* ===================================================================
|
||||
Uhr 1: täglicher 20-Uhr-Fixtermin (bereits bestehend, unverändert im
|
||||
Verhalten) ================================================================= */
|
||||
const radar = document.getElementById("stream-radar");
|
||||
if (radar) {
|
||||
const FIX_STUNDE = 20;
|
||||
zeichneZiffernblatt(document.getElementById("radar-ticks"), document.getElementById("radar-fixmarker"), FIX_STUNDE);
|
||||
|
||||
const nowHand = document.getElementById("radar-now-hand");
|
||||
const nowDot = document.getElementById("radar-now-dot");
|
||||
function zeigerTick() {
|
||||
const jetzt = new Date();
|
||||
zeigerAktualisieren(nowHand, nowDot, jetzt.getHours() + jetzt.getMinutes() / 60 + jetzt.getSeconds() / 3600);
|
||||
}
|
||||
zeigerTick();
|
||||
setInterval(zeigerTick, 15000);
|
||||
|
||||
function naechsterFixTermin() {
|
||||
const jetzt = new Date();
|
||||
const ziel = new Date(jetzt);
|
||||
ziel.setHours(FIX_STUNDE, 0, 0, 0);
|
||||
if (ziel <= jetzt) ziel.setDate(ziel.getDate() + 1);
|
||||
return ziel;
|
||||
}
|
||||
|
||||
const countdownEl = document.getElementById("radar-countdown");
|
||||
const statusText = document.getElementById("radar-status-text");
|
||||
const statusSub = document.getElementById("radar-status-sub");
|
||||
const watchBtn = document.getElementById("radar-watch-btn");
|
||||
|
||||
let istLive = false;
|
||||
let letzterTitel = null;
|
||||
|
||||
function countdownTick() {
|
||||
if (istLive) return;
|
||||
const diffMs = naechsterFixTermin() - new Date();
|
||||
const gesamtSek = Math.max(0, Math.floor(diffMs / 1000));
|
||||
const std = Math.floor(gesamtSek / 3600);
|
||||
const min = Math.floor((gesamtSek % 3600) / 60);
|
||||
const sek = gesamtSek % 60;
|
||||
countdownEl.textContent = `${zweistellig(std)}:${zweistellig(min)}:${zweistellig(sek)}`;
|
||||
}
|
||||
setInterval(countdownTick, 1000);
|
||||
|
||||
function texteNeuSetzen() {
|
||||
const t = window.dogiUebersetzen || ((k) => k);
|
||||
if (istLive) {
|
||||
statusText.textContent = t("sp_radar_live");
|
||||
countdownEl.textContent = letzterTitel || t("sp_radar_live_generic");
|
||||
statusSub.textContent = "";
|
||||
watchBtn.hidden = false;
|
||||
} else {
|
||||
statusText.textContent = t("sp_radar_offline");
|
||||
statusSub.textContent = t("sp_radar_countdown_label");
|
||||
watchBtn.hidden = true;
|
||||
countdownTick();
|
||||
}
|
||||
}
|
||||
|
||||
function liveStatusAnwenden(data) {
|
||||
istLive = !!(data && data.live);
|
||||
letzterTitel = data && data.title ? data.title : null;
|
||||
radar.classList.toggle("ist-live", istLive);
|
||||
texteNeuSetzen();
|
||||
}
|
||||
|
||||
function liveStatusLaden() {
|
||||
return fetch(`${API_BASE_URL}/live-status`, { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then(liveStatusAnwenden)
|
||||
.catch(() => {
|
||||
istLive = false;
|
||||
radar.classList.remove("ist-live");
|
||||
texteNeuSetzen();
|
||||
});
|
||||
}
|
||||
|
||||
liveStatusLaden();
|
||||
setInterval(liveStatusLaden, 30000);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) liveStatusLaden(); });
|
||||
document.addEventListener("dogi-sprache-geaendert", texteNeuSetzen);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Uhr 2: "spezielles Live-Event" aus der Verwaltung (20.08.2026, neu).
|
||||
Nur sichtbar, wenn /special-event ein aktives, noch bevorstehendes
|
||||
Event liefert. Zieluhrzeit kann Tage entfernt sein -> Countdown zeigt
|
||||
bei Bedarf "Xd HH:MM:SS" statt nur HH:MM:SS. ================================================================= */
|
||||
const seWrap = document.getElementById("se-radar-wrap");
|
||||
if (seWrap) {
|
||||
const seRadar = document.getElementById("se-radar");
|
||||
const seNowHand = document.getElementById("se-radar-now-hand");
|
||||
const seNowDot = document.getElementById("se-radar-now-dot");
|
||||
const seCountdown = document.getElementById("se-radar-countdown");
|
||||
const seTitel = document.getElementById("se-radar-titel");
|
||||
const seLink = document.getElementById("se-radar-link");
|
||||
let zielDatum = null;
|
||||
let countdownIntervalId = null;
|
||||
let zeigerIntervalId = null;
|
||||
|
||||
function seCountdownTick() {
|
||||
if (!zielDatum) return;
|
||||
const diffMs = zielDatum - new Date();
|
||||
if (diffMs <= 0) {
|
||||
seRadar.classList.add("ist-live");
|
||||
seCountdown.textContent = window.dogiUebersetzen ? window.dogiUebersetzen("sp_radar_live_generic") : "Live!";
|
||||
clearInterval(countdownIntervalId);
|
||||
return;
|
||||
}
|
||||
const gesamtSek = Math.floor(diffMs / 1000);
|
||||
const tage = Math.floor(gesamtSek / 86400);
|
||||
const std = Math.floor((gesamtSek % 86400) / 3600);
|
||||
const min = Math.floor((gesamtSek % 3600) / 60);
|
||||
const sek = gesamtSek % 60;
|
||||
seCountdown.textContent = tage > 0
|
||||
? `${tage}T ${zweistellig(std)}:${zweistellig(min)}:${zweistellig(sek)}`
|
||||
: `${zweistellig(std)}:${zweistellig(min)}:${zweistellig(sek)}`;
|
||||
}
|
||||
|
||||
// Der "Jetzt"-Zeiger zeigt wie bei Uhr 1 die ECHTE aktuelle Uhrzeit --
|
||||
// der magentafarbene Fixpunkt markiert dagegen die Tageszeit, zu der
|
||||
// das spezielle Event stattfindet. So bleibt die Bildsprache zwischen
|
||||
// beiden Uhren konsistent (nur der Fixpunkt unterscheidet sich).
|
||||
function seZeigerTick() {
|
||||
const jetzt = new Date();
|
||||
zeigerAktualisieren(seNowHand, seNowDot, jetzt.getHours() + jetzt.getMinutes() / 60 + jetzt.getSeconds() / 3600);
|
||||
}
|
||||
|
||||
function specialEventLaden() {
|
||||
return fetch(`${API_BASE_URL}/special-event`, { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const event = data && data.ok ? data.event : null;
|
||||
if (countdownIntervalId) clearInterval(countdownIntervalId);
|
||||
if (zeigerIntervalId) clearInterval(zeigerIntervalId);
|
||||
if (!event) {
|
||||
seWrap.hidden = true;
|
||||
zielDatum = null;
|
||||
return;
|
||||
}
|
||||
zielDatum = new Date(event.zieldatum);
|
||||
seWrap.hidden = false;
|
||||
seRadar.classList.remove("ist-live");
|
||||
seTitel.textContent = window.dogiFeld ? window.dogiFeld(event.titel) : event.titel?.de || "";
|
||||
zeichneZiffernblatt(document.getElementById("se-radar-ticks"), document.getElementById("se-radar-fixmarker"), zielDatum.getHours() + zielDatum.getMinutes() / 60);
|
||||
seZeigerTick();
|
||||
seCountdownTick();
|
||||
zeigerIntervalId = setInterval(seZeigerTick, 15000);
|
||||
countdownIntervalId = setInterval(seCountdownTick, 1000);
|
||||
if (event.link) {
|
||||
seLink.href = event.link;
|
||||
seLink.hidden = false;
|
||||
} else {
|
||||
seLink.hidden = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
seWrap.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
specialEventLaden();
|
||||
setInterval(specialEventLaden, 60000); // reicht -- Aenderungen in der Verwaltung muessen nicht sekundengenau ankommen
|
||||
document.addEventListener("dogi-sprache-geaendert", () => {
|
||||
if (zielDatum) specialEventLaden();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Du bist Streamer und suchst einen Manager? Bewirb dich als Creator bei Spicy Media." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-creator.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-creator-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -70,8 +70,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/forms.js"></script>
|
||||
<script src="assets/js/i18n-bewerben-creator.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/forms.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-bewerben-creator.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="spicymedia">
|
||||
<html lang="de" data-theme="dogfather">
|
||||
<head>
|
||||
<!-- Nutzer-Wunsch 20.08.2026: neues Hero-Bild (Hasi Dog/DogFather/Husky, babyblau statt
|
||||
rot) + Theme von "spicymedia" (Rot/Orange) auf "dogfather" (Babyblau/Lila) umgestellt,
|
||||
damit Knopf-/Link-/Eyebrow-Farben zum neuen, blauen Bild passen. -->
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kooperation anfragen — DOGFATHER UNIVERSE</title>
|
||||
@@ -19,10 +22,10 @@
|
||||
<meta name="twitter:description" content="Kooperationsanfrage an Manager DogFather / Spicy Media." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -65,8 +68,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/forms.js"></script>
|
||||
<script src="assets/js/i18n-bewerben-kooperation.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/forms.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-bewerben-kooperation.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Werde Modi bei Team Dogi — DogFathers persönliches Community-Team." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -79,8 +79,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/forms.js"></script>
|
||||
<script src="assets/js/i18n-bewerben-modi.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/forms.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-bewerben-modi.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Werde Scout für DogFather bei der Spicy Media Agentur — finde neue Talente." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-scouts.jpg');--page-bg-mobile:url('/assets/img/bg-scouts-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -71,8 +71,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/forms.js"></script>
|
||||
<script src="assets/js/i18n-bewerben-scout.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/forms.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-bewerben-scout.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Werde Teil vom Team: Modi bei Team Dogi, Scout oder Creator bei Spicy Media, oder Kooperation anfragen." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-split.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-split-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="hero-banner"><img src="/assets/img/bg-bewerben-split.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="bw_eyebrow">Werde Teil vom Team</span>
|
||||
<h1 data-i18n="bw_h1">Community oder Agentur — such dir deinen Weg</h1>
|
||||
<h1 data-i18n="bw_h1">Community oder Agentur — such dir deinen Weg</h1>
|
||||
<p class="lead worlds" data-i18n="bw_lead">Vier Bewerbungen, drei ganz unterschiedliche Rollen. Wähl, was zu dir passt.</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,7 +85,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-bewerben.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-bewerben.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Der Husky an DogFathers Seite — das tierische Gesicht der HasiDog-Welt." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-casper.jpg');--page-bg-mobile:url('/assets/img/bg-casper-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -85,7 +85,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-casper.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-casper.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- =====================================================================
|
||||
-- Supporter-Abstimmungen (20.08.2026) — "nächste Ausbaustufe" aus
|
||||
-- Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20 (Bonus-Features, dort
|
||||
-- bewusst nicht in den verbindlichen Abnahmekriterien Abschnitt 34, siehe
|
||||
-- Supporter-Abo-System.md "Bewusst nicht Teil dieser ersten Ausbaustufe").
|
||||
-- Dogi erstellt in der Verwaltung eine Frage + Antwortoptionen auf Deutsch,
|
||||
-- die anderen Sprachen werden wie bei "Event des Jahres" automatisch beim
|
||||
-- Speichern übersetzt (lib/translate.js). Jeder DogiCrew-Supporter darf pro
|
||||
-- Abstimmung genau einmal abstimmen (UNIQUE-Constraint statt Anwendungslogik
|
||||
-- — robust auch bei gleichzeitigen Anfragen).
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supporter_polls (
|
||||
id TEXT PRIMARY KEY,
|
||||
question TEXT NOT NULL, -- JSON {de, "de-CH", en, fr, pt}
|
||||
options TEXT NOT NULL, -- JSON-Array von {de, "de-CH", en, fr, pt}
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | closed
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
closed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supporter_poll_votes (
|
||||
id TEXT PRIMARY KEY,
|
||||
poll_id TEXT NOT NULL REFERENCES supporter_polls(id),
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
option_index INTEGER NOT NULL,
|
||||
voted_at TEXT NOT NULL,
|
||||
UNIQUE(poll_id, supporter_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supporter_poll_votes_poll ON supporter_poll_votes (poll_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =====================================================================
|
||||
-- "Stimmen" / Testimonials (20.08.2026) — Fans können auf stimmen.html
|
||||
-- selbst eine Nachricht mit Name + TikTok-Namen einreichen. Landet erst
|
||||
-- als Entwurf in der Verwaltung ("eingereicht") -- öffentlich sichtbar
|
||||
-- wird ein Eintrag NUR, wenn Dogi/VanVan ihn dort ausdrücklich freigeben
|
||||
-- (Nutzer-Wunsch: "die ausgewählten über die verwaltungsseite auf die
|
||||
-- website hinzufügen"). Eigene Tabelle statt app_settings, weil dies eine
|
||||
-- WACHSENDE Liste ist (beliebig viele Einreichungen), keine feste Anzahl
|
||||
-- Slots wie "Event des Jahres".
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS testimonials (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tiktok_username TEXT,
|
||||
nachricht TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'eingereicht', -- eingereicht | freigegeben | abgelehnt
|
||||
created_at TEXT NOT NULL,
|
||||
entschieden_am TEXT,
|
||||
entschieden_von TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_testimonials_status ON testimonials (status, created_at);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- =====================================================================
|
||||
-- Team-Mitglieder (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."
|
||||
--
|
||||
-- Eine gemeinsame Tabelle für alle vier Kategorien (statt vier eigenen),
|
||||
-- weil sie strukturell identisch sind (Name/Rolle/Foto/TikTok/Bio/Zitat) --
|
||||
-- "kategorie" unterscheidet, wo jemand öffentlich erscheint. rang/
|
||||
-- rang_label sind nur für Modis relevant (Pyramiden-Hierarchie auf
|
||||
-- team-modis.html), bei den anderen Kategorien bleiben sie einfach leer.
|
||||
--
|
||||
-- Ergänzt die weiterhin bestehenden, von Hand gepflegten Einträge in
|
||||
-- assets/js/data-modis.js/data-scouts.js/data-creator.js/data-manager.js
|
||||
-- (z.B. VanVan, Patrick, Bananenstift) -- die Website mischt beide Quellen
|
||||
-- zu einer gemeinsamen Liste (siehe window.dogiTeamLaden() in main.js).
|
||||
-- Ein Fehler bei einer Kategorie kann so nie die schon bestehenden,
|
||||
-- fest einprogrammierten Profile verschwinden lassen.
|
||||
--
|
||||
-- slug ist GLOBAL eindeutig (nicht nur pro Kategorie), weil profil.html
|
||||
-- über alle Kategorien hinweg nach genau einem slug sucht.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
kategorie TEXT NOT NULL, -- modi | scout | creator | manager
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
rolle_json TEXT NOT NULL, -- {de, "de-CH", en, fr, pt}
|
||||
foto_url TEXT,
|
||||
tiktok_url TEXT,
|
||||
bio_json TEXT, -- {de, "de-CH", en, fr, pt} oder NULL
|
||||
zitat_json TEXT, -- {de, "de-CH", en, fr, pt} oder NULL
|
||||
rang INTEGER, -- nur Modi-Pyramide, sonst NULL
|
||||
rang_label_json TEXT, -- nur Modi-Pyramide, sonst NULL
|
||||
reihenfolge INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'aktiv', -- aktiv | pausiert
|
||||
erstellt_am TEXT NOT NULL,
|
||||
geaendert_am TEXT NOT NULL,
|
||||
erstellt_von TEXT,
|
||||
geaendert_von TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_kategorie ON team_members (kategorie, status, reihenfolge);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =====================================================================
|
||||
-- Team-Mitglieder: volle Feld-Parität mit den von Hand gepflegten
|
||||
-- Profilen (21.08.2026, Nutzer-Wunsch: "wenn ich eine änderung mache
|
||||
-- ist es auch für jeden für die die schon da sind und die neuen").
|
||||
--
|
||||
-- Damit ein bestehendes Profil (Patrick, Bananenstift, Diene, ...) 1:1
|
||||
-- in die Verwaltung importiert werden kann, OHNE Inhalt zu verlieren,
|
||||
-- braucht team_members dieselben optionalen Felder wie
|
||||
-- data-modis.js/data-scouts.js: intro (kurzer Text neben dem Foto),
|
||||
-- bio_html (ausführliche, mehrabsatzige Vorstellung mit Überschriften),
|
||||
-- extra_cta (zweiter Button auf der Profilkarte, aktuell nur bei Marina
|
||||
-- -> manager.html genutzt).
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE team_members ADD COLUMN intro_json TEXT;
|
||||
ALTER TABLE team_members ADD COLUMN bio_html_json TEXT;
|
||||
ALTER TABLE team_members ADD COLUMN extra_cta_href TEXT;
|
||||
ALTER TABLE team_members ADD COLUMN extra_cta_label_json TEXT;
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Team Dogi ist Familie — die Community rund um DogFather und HasiDog." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-community.jpg');--page-bg-mobile:url('/assets/img/bg-community-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -45,8 +45,8 @@
|
||||
<p data-i18n="com_mehr_2">Discord ist das Community-Zentrum — hier trifft man sich, tauscht sich aus und ist Teil des Alltags von Team Dogi.</p>
|
||||
<a class="btn btn-primary" href="links.html" data-i18n="com_discord_btn">Discord beitreten</a>
|
||||
</div>
|
||||
<div class="img-placeholder" style="aspect-ratio:4/3;">
|
||||
<span data-i18n="com_bild_platzhalter">Community-Bild</span><br><span class="small" data-i18n="com_foto_folgt">Foto folgt</span>
|
||||
<div class="collage-photo" style="aspect-ratio:3/4;border-radius:var(--radius);">
|
||||
<img src="assets/img/community-team-dogi.jpg" alt="Diene, Ghost, VanVan und Dogi bei einem gemeinsamen Treffen von Team Dogi" style="width:100%;height:100%;object-fit:cover;object-position:center 25%;" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -84,7 +84,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-community.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-community.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Content Creator, die Teil von DogFather / Spicy Media sind." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-creator.jpg');--page-bg-mobile:url('/assets/img/bg-creator-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="hero-banner"><img src="/assets/img/bg-creator.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="crt_eyebrow">Manager DogFather — Team</span>
|
||||
<h1 data-i18n="crt_h1">Creator im Team</h1>
|
||||
<h1 data-i18n="crt_h1" data-site-text-key="seitentitel_creator">Creator im Team</h1>
|
||||
<p class="lead worlds" data-i18n="crt_lead">Content Creator, die bei DogFather / Spicy Media mitmachen.</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -49,6 +49,7 @@
|
||||
<span data-i18n="crt_empty">Noch keine Creator-Profile veröffentlicht — bald verfügbar. 🩵</span>
|
||||
<span data-i18n="crt_filter_alle">alle</span>
|
||||
<span data-i18n="crt_profil_btn">Profil ansehen</span>
|
||||
<span data-i18n="crt_kategorie_sonstige">Sonstige</span>
|
||||
</div>
|
||||
<!-- TODO: Noch keine Creator-Profile final geklärt. Sobald Namen/Infos feststehen, in assets/js/data-creator.js ergänzen — diese Seite rendert automatisch. -->
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
@@ -59,14 +60,29 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-creator.js"></script>
|
||||
<script src="assets/js/i18n-creator.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-creator.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-creator.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const grid = document.getElementById("creator-grid");
|
||||
const filterBar = document.getElementById("creator-filters");
|
||||
const list = window.CREATORS || [];
|
||||
let list = window.CREATORS || [];
|
||||
|
||||
// 21.08.2026: über die Verwaltung angelegte Creator ergänzen, BEVOR
|
||||
// entschieden wird, ob die Seite "leer" ist (Nutzer-Wunsch "überall wo
|
||||
// ... Creator ... sind, will ich dass ich die easy über meine
|
||||
// Verwaltungsseite hinzufüge"). Vorher gab es direkt darunter ein
|
||||
// "return", sobald window.CREATORS leer war (bisher der Normalfall, da
|
||||
// noch keine echten Creator-Profile feststehen) -- dadurch wären
|
||||
// admin-gepflegte Creator NIE geladen worden.
|
||||
if (window.dogiTeamLaden) {
|
||||
const neue = await window.dogiTeamLaden("creator");
|
||||
// Runde 2 (21.08.2026, "wenn ich eine änderung mache ist es auch für
|
||||
// jeden"): überschreibt bereits importierte Creator (gleicher slug)
|
||||
// statt sie zu duplizieren.
|
||||
list = window.dogiTeamZusammenfuehren(list, neue);
|
||||
}
|
||||
|
||||
// Texte fürs JS-generierte Grid/Filter kommen aus den versteckten
|
||||
// data-i18n-Elementen oben — main.js übersetzt sie ganz normal (auch bei
|
||||
@@ -88,6 +104,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 21.08.2026: über die Verwaltung angelegte Creator haben (noch) kein
|
||||
// eigenes Genre-Feld (category) -- ohne diesen Fallback würde die
|
||||
// Filterleiste einen kaputten "undefined"-Knopf zeigen.
|
||||
list = list.map((c) => (c.category ? c : { ...c, category: uebersetzen("crt_kategorie_sonstige") }));
|
||||
|
||||
const categories = ["alle", ...new Set(list.map((c) => c.category))];
|
||||
let aktiverFilter = "alle";
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Wer ist DogFather wirklich? Persönlichkeit, Werte und die Geschichte hinter dem Content." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-dogfather-person.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-person-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -158,7 +158,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-dogfather.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-dogfather.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="DogFather" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<style>
|
||||
/* =====================================================================
|
||||
Zugangsseite — bewusst kein braves Standard-Login-Formular, sondern
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="HasiDog: Maskottchen, Musikprojekt und die verspielte, familiäre Seite von Team Dogi." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-hasidog-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -56,6 +56,10 @@
|
||||
<div class="container grid grid-2" style="align-items:center;">
|
||||
<div>
|
||||
<h2 data-i18n="hd_welt_h2">🐰 HasiDog – Eine eigene Welt voller Chaos, Herz und Fantasie</h2>
|
||||
<!-- Eigener TikTok-Account von HasiDog (Nutzer-Wunsch 20.08.2026) -->
|
||||
<p style="margin:.2rem 0 1.2rem;">
|
||||
<a class="btn-tiktok" href="https://www.tiktok.com/@hasidog0804" target="_blank" rel="noopener"><span class="btn-tiktok-note" aria-hidden="true">♪</span><span class="btn-tiktok-text" data-i18n="hd_btn_tiktok">Zur HasiDog TikTok-Seite</span></a>
|
||||
</p>
|
||||
<p data-i18n="hd_welt_1">HasiDog ist mehr als nur eine Figur. Hinter HasiDog steckt eine eigene Welt aus Humor, Musik, Geschichten, Überraschungen und liebenswertem Chaos. 🩵</p>
|
||||
<p data-i18n="hd_welt_2">Hier treffen freche Ideen auf große Gefühle, verrückte Momente auf echte Freundschaft und Fantasie auf Abenteuer. HasiDog steht vollkommen für sich – mit einem eigenen Charakter, einer eigenen Geschichte und einer eigenen Familie.</p>
|
||||
<p class="quote" data-i18n="hd_welt_quote" style="margin-top:1.4rem;">Frech im Kopf. Chaotisch im Herzen. Einzigartig in jeder Geschichte.</p>
|
||||
@@ -182,7 +186,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-hasidog.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-hasidog.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="DOGFATHER UNIVERSE: die Heimat von DogFather als Streamer, der HasiDog-Community und Manager DogFather bei Spicy Media." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="start-seite">
|
||||
<div id="site-header"></div>
|
||||
@@ -81,9 +81,57 @@
|
||||
<p data-i18n="idx_hero_card3_desc">Als Manager bei Spicy Media: Scouts, Creator und faire Zusammenarbeit auf Augenhöhe.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- "Event des Jahres": volle Breite wie die 3 Welt-Kacheln zusammen. Seit
|
||||
20.08.2026 (Nutzer-Wunsch) NUR sichtbar, wenn Dogi/VanVan mindestens ein
|
||||
Event in der Verwaltung ausgefüllt UND aktiviert haben -- kein
|
||||
Standard-/Platzhalterinhalt mehr, kompletter Abschnitt bleibt bis dahin
|
||||
unsichtbar (siehe zeigeJahresEvents() weiter unten). Klick öffnet ein
|
||||
großes Detail-Fenster (Bild/Titel/Text größer, optionaler direkter Link)
|
||||
statt sofort wegzunavigieren. -->
|
||||
<div class="jahres-events" id="jahres-events" hidden>
|
||||
<div class="jahres-events-head">
|
||||
<span class="tag" data-i18n="idx_jahres_events_tag">🏆 Event des Jahres</span>
|
||||
</div>
|
||||
<div class="jahres-events-grid" id="jahres-events-grid">
|
||||
<button type="button" class="jahres-event-card" id="jahres-event-1" data-slot="1" hidden>
|
||||
<span class="jahres-event-shine" aria-hidden="true"></span>
|
||||
<img class="jahres-event-bild" alt="" hidden />
|
||||
<span class="jahres-event-datum"></span>
|
||||
<h3></h3>
|
||||
<p></p>
|
||||
<span class="jahres-event-mehr" data-i18n="idx_jahres_events_mehr">Mehr erfahren →</span>
|
||||
</button>
|
||||
<button type="button" class="jahres-event-card" id="jahres-event-2" data-slot="2" hidden>
|
||||
<span class="jahres-event-shine" aria-hidden="true"></span>
|
||||
<img class="jahres-event-bild" alt="" hidden />
|
||||
<span class="jahres-event-datum"></span>
|
||||
<h3></h3>
|
||||
<p></p>
|
||||
<span class="jahres-event-mehr" data-i18n="idx_jahres_events_mehr">Mehr erfahren →</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Detail-Fenster für "Event des Jahres" -- bewusst außerhalb der Hero-Sektion
|
||||
(die hat eigene transform-/overflow-Effekte für die Polarlicht-/Nebel-
|
||||
Animation, die ein position:fixed-Fenster aus der Zentrierung bringen
|
||||
könnten). -->
|
||||
<div class="jahres-event-modal-overlay" id="jahres-event-modal-overlay" hidden>
|
||||
<div class="jahres-event-modal" role="dialog" aria-modal="true" aria-labelledby="jahres-event-modal-titel">
|
||||
<button type="button" class="jahres-event-modal-close" id="jahres-event-modal-close" aria-label="Schließen">✕</button>
|
||||
<img class="jahres-event-modal-bild" id="jahres-event-modal-bild" alt="" hidden />
|
||||
<div class="jahres-event-modal-body">
|
||||
<span class="jahres-event-modal-datum" id="jahres-event-modal-datum"></span>
|
||||
<h2 id="jahres-event-modal-titel"></h2>
|
||||
<p id="jahres-event-modal-text"></p>
|
||||
<a class="btn btn-primary jahres-event-modal-link" id="jahres-event-modal-link" href="#" target="_blank" rel="noopener" hidden></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= CHARAKTERE: DOGFATHER-HUSKY & HASIDOG-HASE ================= -->
|
||||
<section class="section-tight">
|
||||
<div class="container text-center">
|
||||
@@ -180,23 +228,20 @@
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="idx_team_h2">Team Dogi 🩵</h2>
|
||||
<p class="lead text-center" data-i18n="idx_team_lead" style="max-width:60ch;margin:0 auto;">VanVan & Modis — die Menschen, die DOGFATHER UNIVERSE tragen.</p>
|
||||
<div id="team-preview" class="grid grid-4"></div>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: die vier Personen-Kacheln (VanVan/Diene/
|
||||
Funny/Miss) hier auf der Startseite entfernt -- Überschrift, Text
|
||||
und der Knopf zum Modi-Team bleiben. Die Kacheln selbst gibt es
|
||||
weiterhin vollständig auf team-modis.html. -->
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
<a class="btn btn-outline" href="team-modis.html" data-i18n="idx_team_btn_modi">Modi-Team ansehen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= AKTIONEN TEASER ================= -->
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="idx_aktionen_h2">Was gerade läuft</h2>
|
||||
<div id="aktionen-teaser" class="grid grid-3"></div>
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
<a class="btn btn-outline" href="medien-dogfather.html#events" data-i18n="idx_aktionen_btn">Alle Aktionen & Projekte</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: der "Was gerade läuft"-Aktionen-Teaser
|
||||
(Überschrift + Button "Alle Aktionen & Projekte", direkt hier davor)
|
||||
wurde entfernt. data-aktionen.js/aktion-detail.html bleiben unangetastet,
|
||||
nur die Startseiten-Vorschau ist weg. -->
|
||||
|
||||
<!-- ================= GALERIE TEASER ================= -->
|
||||
<section class="section-tight">
|
||||
@@ -245,11 +290,10 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-modis.js"></script>
|
||||
<script src="assets/js/data-scouts.js"></script>
|
||||
<script src="assets/js/data-aktionen.js"></script>
|
||||
<script src="assets/js/i18n-index.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-modis.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-scouts.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-index.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
// Gleiche URL wie in assets/js/forms.js / postfach.html — läuft seit
|
||||
// 05.08.2026 auf dem eigenen Server statt auf Cloudflare Workers.
|
||||
@@ -331,46 +375,129 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
/* "Event des Jahres": seit 20.08.2026 in der Verwaltung pflegbar (Bild,
|
||||
Text, Link, Aktiv-Schalter je Slot, siehe verwaltung.html +
|
||||
server-internal/routes/events.js). Nutzer-Wunsch 20.08.2026: "die
|
||||
kisten sollen leer sein solang wie ich nichts rein setze, am besten
|
||||
sollen die sogar immer nur erscheinen wenn ich was rein setze und sie
|
||||
aktiviere" — der Server liefert über /events-of-year ohnehin NUR
|
||||
aktivierte Slots (inaktive/leere kommen als null), hier also einfach:
|
||||
kein Eintrag -> Kachel bleibt versteckt, kompletter Abschnitt bleibt
|
||||
versteckt, wenn beide Slots leer sind. Klick öffnet ein großes
|
||||
Detail-Fenster statt sofort zu navigieren. */
|
||||
const feldJahresEvent = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || "");
|
||||
let jahresEventsDaten = [null, null];
|
||||
|
||||
function zeigeJahresEventKachel(slot, daten) {
|
||||
const karte = document.getElementById(`jahres-event-${slot}`);
|
||||
if (!karte) return;
|
||||
if (!daten) {
|
||||
karte.hidden = true;
|
||||
return;
|
||||
}
|
||||
const bild = karte.querySelector(".jahres-event-bild");
|
||||
if (daten.bild) {
|
||||
bild.src = daten.bild;
|
||||
bild.alt = feldJahresEvent(daten.titel);
|
||||
bild.hidden = false;
|
||||
} else {
|
||||
bild.hidden = true;
|
||||
}
|
||||
karte.querySelector(".jahres-event-datum").textContent = daten.datum || "";
|
||||
karte.querySelector("h3").textContent = feldJahresEvent(daten.titel);
|
||||
karte.querySelector("p").textContent = feldJahresEvent(daten.text);
|
||||
karte.hidden = false;
|
||||
}
|
||||
|
||||
function aktualisiereJahresEventsSichtbarkeit() {
|
||||
const aktiveSlots = jahresEventsDaten.filter(Boolean).length;
|
||||
const abschnitt = document.getElementById("jahres-events");
|
||||
const grid = document.getElementById("jahres-events-grid");
|
||||
abschnitt.hidden = aktiveSlots === 0;
|
||||
// Genau 1 aktives Event: Kachel zentriert und etwas größer statt halb
|
||||
// leerem Raster (Nutzer-Wunsch: "kacheln sollen bissl spezieller sein").
|
||||
grid.classList.toggle("jahres-events-grid-einzeln", aktiveSlots === 1);
|
||||
}
|
||||
|
||||
function jahresEventModalOeffnen(slot) {
|
||||
const daten = jahresEventsDaten[slot - 1];
|
||||
if (!daten) return;
|
||||
const bild = document.getElementById("jahres-event-modal-bild");
|
||||
if (daten.bild) {
|
||||
bild.src = daten.bild;
|
||||
bild.alt = feldJahresEvent(daten.titel);
|
||||
bild.hidden = false;
|
||||
} else {
|
||||
bild.hidden = true;
|
||||
}
|
||||
document.getElementById("jahres-event-modal-datum").textContent = daten.datum || "";
|
||||
document.getElementById("jahres-event-modal-titel").textContent = feldJahresEvent(daten.titel);
|
||||
document.getElementById("jahres-event-modal-text").textContent = feldJahresEvent(daten.text);
|
||||
const linkEl = document.getElementById("jahres-event-modal-link");
|
||||
if (daten.link) {
|
||||
linkEl.href = daten.link;
|
||||
linkEl.textContent = feldJahresEvent(window.I18N_PAGE?.idx_jahres_event_modal_link) || "Direkt ansehen →";
|
||||
linkEl.hidden = false;
|
||||
} else {
|
||||
linkEl.hidden = true;
|
||||
}
|
||||
const overlay = document.getElementById("jahres-event-modal-overlay");
|
||||
overlay.hidden = false;
|
||||
requestAnimationFrame(() => overlay.classList.add("offen"));
|
||||
document.body.classList.add("modal-offen");
|
||||
}
|
||||
|
||||
function jahresEventModalSchliessen() {
|
||||
const overlay = document.getElementById("jahres-event-modal-overlay");
|
||||
overlay.classList.remove("offen");
|
||||
document.body.classList.remove("modal-offen");
|
||||
setTimeout(() => { overlay.hidden = true; }, 220);
|
||||
}
|
||||
|
||||
document.getElementById("jahres-event-1")?.addEventListener("click", () => jahresEventModalOeffnen(1));
|
||||
document.getElementById("jahres-event-2")?.addEventListener("click", () => jahresEventModalOeffnen(2));
|
||||
document.getElementById("jahres-event-modal-close")?.addEventListener("click", jahresEventModalSchliessen);
|
||||
document.getElementById("jahres-event-modal-overlay")?.addEventListener("click", (e) => {
|
||||
if (e.target.id === "jahres-event-modal-overlay") jahresEventModalSchliessen();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !document.getElementById("jahres-event-modal-overlay").hidden) jahresEventModalSchliessen();
|
||||
});
|
||||
|
||||
if (API_BASE_URL) {
|
||||
const ladeJahresEvents = () =>
|
||||
fetch(`${API_BASE_URL}/events-of-year`, { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data?.ok) return;
|
||||
jahresEventsDaten = data.events;
|
||||
zeigeJahresEventKachel(1, data.events[0]);
|
||||
zeigeJahresEventKachel(2, data.events[1]);
|
||||
aktualisiereJahresEventsSichtbarkeit();
|
||||
})
|
||||
.catch(() => {
|
||||
/* Kein Fehler nötig -- der Abschnitt bleibt einfach unsichtbar. */
|
||||
});
|
||||
ladeJahresEvents();
|
||||
// Bei Sprachwechsel mit den zuletzt geladenen Daten neu rendern (kein
|
||||
// erneuter Server-Aufruf nötig) — Titel/Text stehen ja bereits in allen
|
||||
// Sprachen im geladenen Objekt.
|
||||
document.addEventListener("dogi-sprache-geaendert", () => {
|
||||
zeigeJahresEventKachel(1, jahresEventsDaten[0]);
|
||||
zeigeJahresEventKachel(2, jahresEventsDaten[1]);
|
||||
});
|
||||
}
|
||||
|
||||
// Stats: Team-Größe aus den Datenobjekten berechnen
|
||||
const teamCount = (window.MODIS || []).length + (window.SCOUTS || []).length;
|
||||
const statTeam = document.getElementById("stat-team");
|
||||
if (statTeam) statTeam.textContent = teamCount > 0 ? `${teamCount}+` : "–";
|
||||
|
||||
// Team-Vorschau: VanVan zuerst, dann die nächsten nach Rang, plus 1-2 Scouts
|
||||
const teamPreview = document.getElementById("team-preview");
|
||||
if (teamPreview && window.MODIS) {
|
||||
const feld = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || "");
|
||||
const people = [...window.MODIS].sort((a, b) => a.rank - b.rank).slice(0, 4);
|
||||
const renderTeamPreview = () => {
|
||||
teamPreview.innerHTML = people
|
||||
.map(
|
||||
(p) => `
|
||||
<a class="profile-card" href="${p.page || `profil.html?p=${p.slug}`}">
|
||||
<div class="avatar">${(p.avatar || p.photo) ? `<img src="${p.avatar || p.photo}" alt="${p.name}" loading="lazy">` : `<span class="avatar-initial">${p.name.charAt(0).toUpperCase()}</span>`}</div>
|
||||
<h3>${p.name}</h3>
|
||||
<div class="role">${feld(p.role)}</div>
|
||||
</a>`
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
renderTeamPreview();
|
||||
document.addEventListener("dogi-sprache-geaendert", renderTeamPreview);
|
||||
}
|
||||
// Nutzer-Wunsch 21.08.2026: die Team-Vorschau (vier Personen-Kacheln unter
|
||||
// "Team Dogi 🩵") wurde entfernt -- der zugehörige Render-Code stand hier.
|
||||
// data-modis.js/data-scouts.js bleiben eingebunden, weil die Statistik-Zeile
|
||||
// weiter oben ("Menschen im Team Dogi") sie weiterhin auszählt.
|
||||
|
||||
// Aktionen-Teaser: die ersten 3 Einträge aus data-aktionen.js
|
||||
const aktionenTeaser = document.getElementById("aktionen-teaser");
|
||||
if (aktionenTeaser && window.AKTIONEN) {
|
||||
aktionenTeaser.innerHTML = window.AKTIONEN.slice(0, 3)
|
||||
.map(
|
||||
(a) => `
|
||||
<a class="card" href="aktion-detail.html?id=${a.id}">
|
||||
<span class="tag">${a.tag} · ${a.status}</span>
|
||||
<h3>${a.title}</h3>
|
||||
<p>${a.summary}</p>
|
||||
</a>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Kontakt, Impressum und Datenschutz von DOGFATHER UNIVERSE." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="site-header"></div>
|
||||
@@ -126,7 +126,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-kontakt.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-kontakt.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Alle Social-Media- und Musik-Links von DogFather an einem Ort — plus QR-Codes." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* Rabattcodes: an das Supporter-Abo gekoppelt (Nutzer-Feedback
|
||||
03.08.2026: "verbinde es mit dem Abo... wenn die Leute drauf drücken
|
||||
@@ -140,8 +140,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-links.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-links.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="DogFather als Manager bei der Spicy Media Agency: Scouts, Creator und faire Zusammenarbeit." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-manager.jpg');--page-bg-mobile:url('/assets/img/bg-manager-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -96,6 +96,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- "Weitere Manager" (21.08.2026, Nutzer-Wunsch: "überall wo ... Manager
|
||||
... sind, will ich dass ich die easy über meine Verwaltungsseite
|
||||
hinzufüge"). Aktuell gibt es nur DogFather selbst als Manager -- diese
|
||||
Sektion bleibt deshalb komplett unsichtbar (hidden), bis über die
|
||||
Verwaltung mindestens ein weiterer Manager angelegt wurde. Gleiches
|
||||
flaches Karten-Raster wie team-scouts.html. -->
|
||||
<section class="section-tight" id="weitere-manager-root" hidden>
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="mgr_weitere_h2" data-site-text-key="seitentitel_manager">Weitere Manager</h2>
|
||||
<div id="weitere-manager-grid" class="grid grid-4"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="mgr_team_h2">Willst du dazugehören?</h2>
|
||||
@@ -126,7 +139,54 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-manager.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-manager.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-manager.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
// "Weitere Manager" (21.08.2026) -- siehe Kommentar bei #weitere-manager-root
|
||||
// oben. Aktuell IMMER window.MANAGERS === [] (siehe data-manager.js), Inhalt
|
||||
// kommt also ausschließlich über die Verwaltung (dogiTeamLaden). Bleibt
|
||||
// komplett unsichtbar, solange niemand eingetragen ist.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const root = document.getElementById("weitere-manager-root");
|
||||
const grid = document.getElementById("weitere-manager-grid");
|
||||
if (!root || !grid) return;
|
||||
|
||||
const feld = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || "");
|
||||
const t = (schluessel) => (window.dogiUebersetzen ? window.dogiUebersetzen(schluessel) : schluessel);
|
||||
|
||||
const renderWeitereManager = () => {
|
||||
const liste = window.MANAGERS || [];
|
||||
if (!liste.length) {
|
||||
root.hidden = true;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = liste.map((m) => {
|
||||
const quoteText = feld(m.quote);
|
||||
return `
|
||||
<a class="profile-card" href="profil.html?p=${m.slug}">
|
||||
<div class="avatar">${(m.avatar || m.photo) ? `<img src="${m.avatar || m.photo}" alt="${m.name}" loading="lazy">` : `<span class="avatar-initial">${m.name.charAt(0).toUpperCase()}</span>`}</div>
|
||||
<h3>${m.name}</h3>
|
||||
<div class="role">${feld(m.role)}</div>
|
||||
${quoteText ? `<p class="quote">„${quoteText}"</p>` : ""}
|
||||
<span class="btn btn-outline" style="margin-top:.7rem;pointer-events:none;">${t("mgr_weitere_profil_btn")}</span>
|
||||
</a>`;
|
||||
}).join("");
|
||||
root.hidden = false;
|
||||
};
|
||||
|
||||
document.addEventListener("dogi-sprache-geaendert", renderWeitereManager);
|
||||
|
||||
if (window.dogiTeamLaden) {
|
||||
window.dogiTeamLaden("manager").then((neue) => {
|
||||
if (!neue.length) return;
|
||||
// Runde 2 (21.08.2026, "wenn ich eine änderung mache ist es auch für
|
||||
// jeden"): überschreibt bereits importierte Manager statt zu duplizieren.
|
||||
window.MANAGERS = window.dogiTeamZusammenfuehren(window.MANAGERS || [], neue);
|
||||
renderWeitereManager();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "DogiCrew-Verwaltung",
|
||||
"short_name": "Verwaltung",
|
||||
"description": "Interner Admin-Bereich: DogiCrew-Supporter, Prämien, Event des Jahres, Abstimmungen. Nur mit Zugangscode.",
|
||||
"start_url": "/verwaltung.html",
|
||||
"id": "/verwaltung.html",
|
||||
"scope": "/verwaltung.html",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0e0a16",
|
||||
"theme_color": "#0e0a16",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/icon-192-admin.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-512-admin.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-192-maskable-admin.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-512-maskable-admin.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Fotos und Videos rund um Husky Casper an einem Ort." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" data-welt="casper" style="--page-bg:url('/assets/img/bg-casper.jpg');--page-bg-mobile:url('/assets/img/bg-casper-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -59,10 +59,10 @@
|
||||
</div>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-media.js"></script>
|
||||
<script src="assets/js/data-videos.js"></script>
|
||||
<script src="assets/js/medien-welt.js"></script>
|
||||
<script src="assets/js/i18n-medien-casper.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-media.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-videos.js?v=20260821n"></script>
|
||||
<script src="assets/js/medien-welt.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-medien-casper.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Fotos, Videos und Events aus DogFathers Welt an einem Ort." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" data-welt="dogfather" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -65,11 +65,11 @@
|
||||
</div>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-media.js"></script>
|
||||
<script src="assets/js/data-videos.js"></script>
|
||||
<script src="assets/js/data-aktionen.js"></script>
|
||||
<script src="assets/js/medien-welt.js"></script>
|
||||
<script src="assets/js/i18n-medien-dogfather.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-media.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-videos.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-aktionen.js?v=20260821n"></script>
|
||||
<script src="assets/js/medien-welt.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-medien-dogfather.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Fotos, Videos und Events aus der HasiDog-Welt an einem Ort." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" data-welt="hasidog" style="--page-bg:url('/assets/img/bg-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-hasidog-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -65,11 +65,11 @@
|
||||
</div>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-media.js"></script>
|
||||
<script src="assets/js/data-videos.js"></script>
|
||||
<script src="assets/js/data-aktionen.js"></script>
|
||||
<script src="assets/js/medien-welt.js"></script>
|
||||
<script src="assets/js/i18n-medien-hasidog.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-media.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-videos.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-aktionen.js?v=20260821n"></script>
|
||||
<script src="assets/js/medien-welt.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-medien-hasidog.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="robots" content="noindex" /> <!-- generische URL, echte Team-Seiten verlinken hierher -->
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
.profil-photo { aspect-ratio: 4/5; max-width: 420px; margin: 0 auto; }
|
||||
.profil-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
@@ -130,18 +130,25 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-modis.js"></script>
|
||||
<script src="assets/js/data-scouts.js"></script>
|
||||
<script src="assets/js/data-creator.js"></script>
|
||||
<script src="assets/js/i18n-profil.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-modis.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-scouts.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-creator.js?v=20260821n"></script>
|
||||
<script src="assets/js/data-manager.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-profil.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const slug = new URLSearchParams(location.search).get("p") || "";
|
||||
const modis = window.MODIS || [];
|
||||
const scouts = window.SCOUTS || [];
|
||||
const creators = window.CREATORS || [];
|
||||
const alle = [...modis, ...scouts, ...creators];
|
||||
const managers = window.MANAGERS || [];
|
||||
// 21.08.2026: über die Verwaltung angelegte Mitglieder ALLER Kategorien
|
||||
// dazuholen -- profil.html weiß vorab nicht, zu welcher Kategorie ein
|
||||
// slug gehört, deshalb kommt hier (ohne Kategorie-Filter) einfach alles
|
||||
// zusammen zurück und wird unten normal mitgesucht.
|
||||
const admins = window.dogiTeamLaden ? await window.dogiTeamLaden() : [];
|
||||
const alle = [...modis, ...scouts, ...creators, ...managers, ...admins];
|
||||
const person = alle.find((p) => p.slug === slug);
|
||||
|
||||
// Für alle Rahmentexte, die rein per JS gesetzt werden (Name/Rolle/Bio
|
||||
@@ -170,16 +177,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const istScout = scouts.includes(person);
|
||||
const istCreator = creators.includes(person);
|
||||
const welt = istScout || istCreator ? "spicymedia" : "dogfather";
|
||||
// 21.08.2026: admin-gepflegte Personen (aus dogiTeamLaden) stecken NICHT
|
||||
// in den statischen modis/scouts/creators/managers-Arrays selbst (die
|
||||
// reference-Prüfung .includes(person) würde für sie also fälschlich
|
||||
// "false" liefern) -- deshalb zusätzlich über das von dogiTeamLaden
|
||||
// gesetzte person.kategorie-Feld prüfen.
|
||||
const istScout = scouts.includes(person) || person.kategorie === "scout";
|
||||
const istCreator = creators.includes(person) || person.kategorie === "creator";
|
||||
const istManager = managers.includes(person) || person.kategorie === "manager";
|
||||
const welt = istScout || istCreator || istManager ? "spicymedia" : "dogfather";
|
||||
document.documentElement.setAttribute("data-theme", welt);
|
||||
|
||||
// Passendes Weltmotiv nur noch als weichgezeichnete Stimmung ganz im
|
||||
// Hintergrund (body.mit-hintergrund) — bewusst KEIN scharfes Banner-Bild
|
||||
// mehr oben im Kopfbereich, damit das Profil der Person (Foto/Name/Bio)
|
||||
// sofort beim Öffnen der Seite im Fokus steht, ohne Banner davor.
|
||||
const bgBasis = istScout || istCreator ? "bg-scouts" : "bg-dogfather";
|
||||
const bgBasis = istScout || istCreator || istManager ? "bg-scouts" : "bg-dogfather";
|
||||
document.body.style.setProperty("--page-bg", `url('/assets/img/${bgBasis}.jpg')`);
|
||||
document.body.style.setProperty("--page-bg-mobile", `url('/assets/img/${bgBasis}-mobile.jpg')`);
|
||||
|
||||
@@ -241,6 +254,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
? t("pr_eyebrow_scout")
|
||||
: istCreator
|
||||
? t("pr_eyebrow_creator")
|
||||
: istManager
|
||||
? t("pr_eyebrow_manager")
|
||||
: t("pr_eyebrow_default");
|
||||
|
||||
document.getElementById("profil-hero-name").textContent = person.name;
|
||||
@@ -286,6 +301,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
} else if (istCreator) {
|
||||
zurueck.href = "creator.html";
|
||||
zurueck.textContent = t("pr_back_creator");
|
||||
} else if (istManager) {
|
||||
zurueck.href = "manager.html";
|
||||
zurueck.textContent = t("pr_back_manager");
|
||||
} else {
|
||||
zurueck.href = "team-modis.html";
|
||||
zurueck.textContent = t("pr_back_modis");
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* Leere-Zustand-Kachel im "Gutschein"-Look: gestrichelter Rand wie ein
|
||||
ausgeschnittener Coupon, damit auch OHNE echte Codes schon klar ist,
|
||||
@@ -131,8 +131,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-rabattcodes.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-rabattcodes.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* =====================================================================
|
||||
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1.
|
||||
Führt beim ersten Start alle vier Migrationen aus ../cloudflare-worker/migrations/
|
||||
Führt beim ersten Start alle Migrationen aus ../cloudflare-worker/migrations/
|
||||
in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings,
|
||||
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop
|
||||
ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden,
|
||||
deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden.
|
||||
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten, 0005 Supporter-Abstimmungen)
|
||||
— anders als bei VanVans Shop ist hier NIE alles in einer konsolidierten
|
||||
schema.sql zusammengefasst worden, deshalb müssen wir hier tatsächlich alle
|
||||
der Reihe nach anwenden.
|
||||
===================================================================== */
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
@@ -27,6 +28,10 @@ const MIGRATION_FILES = [
|
||||
"0002_app_settings.sql",
|
||||
"0003_supporter_abo.sql",
|
||||
"0004_supporter_oauth_providers.sql",
|
||||
"0005_supporter_polls.sql",
|
||||
"0006_testimonials.sql",
|
||||
"0007_team_members.sql",
|
||||
"0008_team_members_rich.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -27,6 +27,9 @@ import "dotenv/config";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cron from "node-cron";
|
||||
import multer from "multer";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import { initDb } from "./db.js";
|
||||
import { json } from "./lib/http.js";
|
||||
@@ -60,6 +63,18 @@ import {
|
||||
import {
|
||||
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
||||
} from "./routes/supporter-admin.js";
|
||||
import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./routes/events.js";
|
||||
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
|
||||
import {
|
||||
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
|
||||
} from "./routes/testimonials.js";
|
||||
import {
|
||||
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
|
||||
} from "./routes/polls.js";
|
||||
import {
|
||||
getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto,
|
||||
} from "./routes/team.js";
|
||||
import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js";
|
||||
|
||||
initDb();
|
||||
|
||||
@@ -68,6 +83,39 @@ const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.disable("x-powered-by"); // verrät sonst unnötig den eingesetzten Technik-Stack
|
||||
|
||||
/* Bild-Uploads für admin-gepflegte Inhalte (aktuell: "Event des Jahres",
|
||||
20.08.2026). Liegt bewusst NEBEN der Datenbank unter /var/lib/... statt im
|
||||
Git-Repo-Ordner — Uploads sollen einen `git pull`/Deploy unbeschadet
|
||||
überstehen und nicht versehentlich mit eingecheckt werden. */
|
||||
const UPLOADS_BASIS_DIR = process.env.UPLOADS_DIR || "/var/lib/dogfather-internal/uploads";
|
||||
const EVENTS_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/events`;
|
||||
mkdirSync(EVENTS_UPLOADS_DIR, { recursive: true });
|
||||
// Team-Fotos (21.08.2026, "Verwaltungsseite perfektionieren"-Auftrag) --
|
||||
// eigener Unterordner, gleiches Muster wie bei den Event-Bildern oben.
|
||||
const TEAM_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/team`;
|
||||
mkdirSync(TEAM_UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const ERLAUBTE_BILD_TYPEN = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const bildUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, EVENTS_UPLOADS_DIR),
|
||||
// Zufälliger Dateiname (crypto.randomUUID) statt des Originalnamens —
|
||||
// verhindert Pfad-/Namenskollisionen und Path-Traversal-Versuche über
|
||||
// einen manipulierten Dateinamen komplett, ganz ohne Validierung nötig.
|
||||
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
|
||||
});
|
||||
const teamBildUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, TEAM_UPLOADS_DIR),
|
||||
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
@@ -76,6 +124,10 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
// Bild-Uploads oeffentlich ausliefern (index.html zeigt "Event des Jahres"-Bilder
|
||||
// JEDER Besucherin/JEDEM Besucher, ohne Login) — nur lesend, Schreiben bleibt
|
||||
// ausschliesslich ueber /admin/events/upload-image mit EVENTS_MANAGE-Berechtigung.
|
||||
app.use("/uploads", express.static(UPLOADS_BASIS_DIR));
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "2mb",
|
||||
@@ -124,6 +176,12 @@ app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-unive
|
||||
app.post("/submit", wrap(submitApplication));
|
||||
app.get("/live-status", wrap(handleLiveStatusGet));
|
||||
app.post("/auth/login", wrap(login));
|
||||
app.get("/events-of-year", wrap(getEventsPublic));
|
||||
app.get("/special-event", wrap(getSpecialEventPublic));
|
||||
app.post("/testimonials/submit", wrap(submitTestimonial));
|
||||
app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
|
||||
app.get("/team", wrap(getTeamPublic));
|
||||
app.get("/site-texts", wrap(getSiteTextsPublic));
|
||||
|
||||
app.post("/supporter/register", wrap(registerSupporter));
|
||||
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
|
||||
@@ -156,6 +214,28 @@ app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
|
||||
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
|
||||
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
|
||||
|
||||
// Startseite: "Event des Jahres" (20.08.2026)
|
||||
app.post("/admin/events/list", requireTeamSession, withSession(getEventsAdmin));
|
||||
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
|
||||
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
|
||||
|
||||
// Spezielles Live-Event mit Countdown auf streamplan.html (20.08.2026)
|
||||
app.post("/admin/special-event", requireTeamSession, withSession(getSpecialEventAdmin));
|
||||
app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecialEvent));
|
||||
|
||||
// "Stimmen"-Warteschlange (20.08.2026)
|
||||
app.post("/admin/testimonials/list", requireTeamSession, withSession(getTestimonialsAdmin));
|
||||
app.post("/admin/testimonials/status", requireTeamSession, withSession(setTestimonialStatus));
|
||||
app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTestimonial));
|
||||
|
||||
// Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026
|
||||
app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin));
|
||||
app.post("/admin/team/save", requireTeamSession, withSession(saveTeamMember));
|
||||
app.post("/admin/team/import", requireTeamSession, withSession(importLegacyMember));
|
||||
app.post("/admin/team/delete", requireTeamSession, withSession(deleteTeamMember));
|
||||
app.post("/admin/team/upload-photo", requireTeamSession, teamBildUpload.single("image"), withSession(uploadTeamPhoto));
|
||||
app.post("/admin/site-texts/save", requireTeamSession, withSession(saveSiteText));
|
||||
|
||||
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
|
||||
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
|
||||
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
|
||||
@@ -185,6 +265,13 @@ app.post("/admin/supporter/premium/update", requireTeamSession, withSession(upda
|
||||
app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections));
|
||||
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection));
|
||||
|
||||
// Supporter-Abstimmungen (20.08.2026)
|
||||
app.post("/admin/polls/list", requireTeamSession, withSession(listPolls));
|
||||
app.post("/admin/polls/create", requireTeamSession, withSession(createPoll));
|
||||
app.post("/admin/polls/close", requireTeamSession, withSession(closePoll));
|
||||
app.post("/admin/polls/reopen", requireTeamSession, withSession(reopenPoll));
|
||||
app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
|
||||
|
||||
/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
|
||||
|
||||
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
|
||||
@@ -197,6 +284,8 @@ app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(delete
|
||||
app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
|
||||
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
|
||||
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription));
|
||||
app.post("/supporter/polls/active", requireSupporterAuth, withSupporter(getActivePollPublic));
|
||||
app.post("/supporter/polls/vote", requireSupporterAuth, withSupporter(voteOnPoll));
|
||||
|
||||
app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
|
||||
|
||||
@@ -212,6 +301,10 @@ app.use((err, req, res, next) => {
|
||||
if (err?.type === "entity.too.large") {
|
||||
return json(res, { ok: false, error: "Die Anfrage ist zu groß." }, 413);
|
||||
}
|
||||
if (err instanceof multer.MulterError) {
|
||||
const nachricht = err.code === "LIMIT_FILE_SIZE" ? "Das Bild ist zu groß (maximal 5 MB)." : "Bild-Upload fehlgeschlagen.";
|
||||
return json(res, { ok: false, error: nachricht }, 400);
|
||||
}
|
||||
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
|
||||
if (res.headersSent) return;
|
||||
return json(res, { ok: false, error: "Interner Fehler." }, 500);
|
||||
|
||||
@@ -45,6 +45,11 @@ export const PERMISSIONS = {
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
POLLS_MANAGE: "Supporter-Abstimmungen erstellen & auswerten",
|
||||
|
||||
EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten",
|
||||
TESTIMONIALS_MANAGE: "Stimmen/Testimonials verwalten",
|
||||
TEAM_MANAGE: "Team-Mitglieder verwalten (Modis/Scouts/Creator/Manager)",
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
@@ -54,7 +59,10 @@ export const PERMISSION_GROUPS = [
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS", "POLLS_MANAGE"] },
|
||||
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
|
||||
{ label: "Stimmen", keys: ["TESTIMONIALS_MANAGE"] },
|
||||
{ label: "Team", keys: ["TEAM_MANAGE"] },
|
||||
];
|
||||
|
||||
export const OWNER_ONLY_ACTIONS = [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =====================================================================
|
||||
lib/translate.js — automatische Übersetzung für admin-gepflegte Texte
|
||||
(aktuell nur "Event des Jahres" auf der Startseite, 20.08.2026).
|
||||
|
||||
Bewusste Entscheidung (Nutzer-Rückfrage 20.08.2026): Dogi tippt Titel/Text
|
||||
nur auf Deutsch in die Verwaltung ein, die anderen Sprachen entstehen
|
||||
automatisch beim Speichern — NICHT die sonst auf der ganzen Seite geltende
|
||||
Regel "immer echte, geprüfte Übersetzungen", sondern eine bewusste
|
||||
Ausnahme für diesen einen admin-gepflegten Bereich. Maschinen-Übersetzung
|
||||
ist NIE so gut wie eine echte — für schnell änderbare Startseiten-Teaser
|
||||
akzeptabel, für Rechtstexte/feste Seiteninhalte weiterhin NICHT verwenden.
|
||||
|
||||
de-CH bekommt bewusst NICHT automatisch übersetzt: der Website-Dialekt
|
||||
(Solothurn/Zürich-nahe Schreibweise, siehe main.js-Kommentar zu I18N_CORE)
|
||||
ist keine von Übersetzungs-APIs unterstützte Zielsprache — eine
|
||||
automatische "Übersetzung" nach de-CH würde bestenfalls Standarddeutsch
|
||||
zurückgeben, das wäre nicht ehrlich als "Dialekt" beschriftet. de-CH
|
||||
bekommt stattdessen exakt denselben Text wie de.
|
||||
|
||||
MyMemory (https://mymemory.translated.net) statt Google/DeepL: völlig
|
||||
kostenlos, kein API-Key/Anmeldung nötig (passt zu "Kosten möglichst 0€"),
|
||||
für kurze Teaser-Texte ausreichende Qualität. Absichtlich KEIN lokal
|
||||
gehosteter Übersetzungsdienst (z.B. LibreTranslate) — bei der seltenen
|
||||
Nutzung (2 kurze Texte, gelegentlich geändert) wäre ein dauerhaft
|
||||
laufender zusätzlicher Dienst auf dem ohnehin schon mehrfach belegten
|
||||
Server unverhältnismäßig viel Aufwand für den tatsächlichen Bedarf. */
|
||||
|
||||
const SPRACHEN_ZIEL = ["en", "fr", "pt"];
|
||||
|
||||
async function uebersetzeEinzeln(text, ziel) {
|
||||
if (!text || !text.trim()) return "";
|
||||
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=de|${ziel}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) throw new Error(`Übersetzungsdienst antwortete mit ${res.status}`);
|
||||
const data = await res.json();
|
||||
const uebersetzt = data?.responseData?.translatedText;
|
||||
if (!uebersetzt || typeof uebersetzt !== "string") throw new Error("Keine Übersetzung erhalten.");
|
||||
return uebersetzt;
|
||||
}
|
||||
|
||||
/** Übersetzt einen deutschen Text nach en/fr/pt, de-CH = 1:1 Kopie von de.
|
||||
Bei Fehlern (Dienst nicht erreichbar etc.) fällt die jeweilige Sprache
|
||||
auf den deutschen Text zurück, statt die ganze Speicherung scheitern zu
|
||||
lassen — "fail closed" mit sichtbarem Ergebnis statt kaputter Anzeige. */
|
||||
export async function uebersetzeAlleSprachen(textDe) {
|
||||
const ergebnis = { de: textDe || "", "de-CH": textDe || "" };
|
||||
await Promise.all(
|
||||
SPRACHEN_ZIEL.map(async (ziel) => {
|
||||
try {
|
||||
ergebnis[ziel] = await uebersetzeEinzeln(textDe, ziel);
|
||||
} catch (err) {
|
||||
console.error(`[translate] ${ziel} fehlgeschlagen, falle auf Deutsch zurück:`, err?.message || err);
|
||||
ergebnis[ziel] = textDe || "";
|
||||
}
|
||||
})
|
||||
);
|
||||
return ergebnis;
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =====================================================================
|
||||
routes/events.js — "Event des Jahres" auf der Startseite (index.html),
|
||||
admin-pflegbar aus der Verwaltung heraus (Nutzer-Wunsch 20.08.2026:
|
||||
"will ich in der admin seite das selbst gestalten können, mit bild und
|
||||
text und am besten auch einen link"). Zeigt IMMER genau 2 feste Slots
|
||||
(1 und 2) — kein wachsender Katalog, deshalb genügt die bereits
|
||||
bestehende app_settings-Tabelle (key/value) statt einer eigenen Tabelle.
|
||||
|
||||
Öffentlicher Lesezugriff (getEventsPublic) ohne Session, damit
|
||||
index.html die Inhalte für jede Besucherin/jeden Besucher laden kann —
|
||||
Schreibzugriff nur mit EVENTS_MANAGE-Berechtigung (Owner immer erlaubt,
|
||||
siehe can()). ===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function settingsKey(slot) {
|
||||
return `event_des_jahres_${slot}`;
|
||||
}
|
||||
|
||||
function ladeSlot(slot) {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(settingsKey(slot));
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: liefert nur AKTIVIERTE Slots (Nutzer-Wunsch
|
||||
20.08.2026: "die kisten sollen leer sein solang wie ich nichts rein
|
||||
setze, am besten sollen die sogar immer nur erscheinen wenn ich was
|
||||
rein setze und sie aktiviere") -- ein Slot mit gespeichertem Inhalt,
|
||||
aber ohne gesetztes "aktiv", bleibt für Besucher:innen unsichtbar
|
||||
(Entwurfsmodus), obwohl er in der Verwaltung längst vorausgefüllt ist. */
|
||||
export async function getEventsPublic(req, res) {
|
||||
const gibFreiWennAktiv = (slot) => {
|
||||
const eintrag = ladeSlot(slot);
|
||||
return eintrag && eintrag.aktiv ? eintrag : null;
|
||||
};
|
||||
return json(res, { ok: true, events: [gibFreiWennAktiv(1), gibFreiWennAktiv(2)] });
|
||||
}
|
||||
|
||||
/** Für die Verwaltung: liefert BEIDE Slots roh (auch inaktive/Entwürfe),
|
||||
damit das Formular beim Öffnen alles vorausfüllen kann. */
|
||||
export async function getEventsAdmin(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] });
|
||||
}
|
||||
|
||||
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). */
|
||||
export async function uploadEventImage(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
|
||||
const url = `/uploads/events/${req.file.filename}`;
|
||||
logAction(session.actor, "events.image_uploaded", req.file.filename, null);
|
||||
return json(res, { ok: true, url });
|
||||
}
|
||||
|
||||
export async function saveEvent(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const slot = Number(body.slot);
|
||||
if (slot !== 1 && slot !== 2) return json(res, { ok: false, error: "Ungültiger Slot (nur 1 oder 2)." }, 400);
|
||||
|
||||
const datum = String(body.datum || "").trim().slice(0, 80);
|
||||
const titelDe = String(body.titel || "").trim().slice(0, 120);
|
||||
const textDe = String(body.text || "").trim().slice(0, 400);
|
||||
const link = String(body.link || "").trim().slice(0, 300) || null;
|
||||
const bild = String(body.bild || "").trim().slice(0, 300) || null;
|
||||
const aktiv = !!body.aktiv;
|
||||
|
||||
if (!titelDe || !textDe) {
|
||||
return json(res, { ok: false, error: "Titel und Text sind Pflicht." }, 400);
|
||||
}
|
||||
// Nur echte http(s)-Links zulassen (kein javascript:-Link etc.)
|
||||
if (link && !/^https?:\/\//i.test(link)) {
|
||||
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
|
||||
}
|
||||
// Aktivieren nur mit vollständigem Pflichtinhalt möglich -- verhindert ein
|
||||
// leeres/kaputtes, aber "aktives" Feld auf der Startseite.
|
||||
if (aktiv && (!titelDe || !textDe)) {
|
||||
return json(res, { ok: false, error: "Zum Aktivieren müssen Titel und Text ausgefüllt sein." }, 400);
|
||||
}
|
||||
|
||||
const [titel, text] = await Promise.all([uebersetzeAlleSprachen(titelDe), uebersetzeAlleSprachen(textDe)]);
|
||||
|
||||
const eintrag = {
|
||||
datum, titel, text, link, bild, aktiv,
|
||||
aktualisiertVon: session.actor,
|
||||
aktualisiertAm: new Date().toISOString(),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(settingsKey(slot), JSON.stringify(eintrag), eintrag.aktualisiertAm);
|
||||
|
||||
logAction(session.actor, "events.saved", String(slot), { titel: titelDe });
|
||||
return json(res, { ok: true, event: eintrag });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* =====================================================================
|
||||
routes/polls.js — Supporter-Abstimmungen (20.08.2026), "nächste
|
||||
Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20.
|
||||
|
||||
Admin-Seite (verwaltung.html): Frage + Antwortoptionen auf Deutsch
|
||||
erstellen (POLLS_MANAGE-Recht, Owner immer erlaubt), automatische
|
||||
Übersetzung wie bei "Event des Jahres" (lib/translate.js), Ergebnisse
|
||||
live einsehen, Abstimmung schließen oder löschen.
|
||||
|
||||
Supporter-Bereich: genau EINE aktive Abstimmung wird angezeigt (die
|
||||
zuletzt erstellte), einmal Stimme abgeben (UNIQUE(poll_id, supporter_id)
|
||||
in der Datenbank verhindert doppelte Stimmen robust, auch bei
|
||||
gleichzeitigen Anfragen — keine reine Anwendungslogik-Prüfung).
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
const MIN_OPTIONEN = 2;
|
||||
const MAX_OPTIONEN = 6;
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function parsePoll(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
question: JSON.parse(row.question),
|
||||
options: JSON.parse(row.options),
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
createdBy: row.created_by,
|
||||
closedAt: row.closed_at,
|
||||
};
|
||||
}
|
||||
|
||||
function voteCountsFor(pollId, optionCount) {
|
||||
const rows = db
|
||||
.prepare(`SELECT option_index, COUNT(*) as n FROM supporter_poll_votes WHERE poll_id = ? GROUP BY option_index`)
|
||||
.all(pollId);
|
||||
const counts = new Array(optionCount).fill(0);
|
||||
for (const r of rows) {
|
||||
if (r.option_index >= 0 && r.option_index < optionCount) counts[r.option_index] = r.n;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/* ---------- Admin (interne Team-Session, POLLS_MANAGE) ---------- */
|
||||
|
||||
export async function createPoll(req, res, session) {
|
||||
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const fragestellungDe = String(body.frage || "").trim().slice(0, 200);
|
||||
const optionenDe = Array.isArray(body.optionen)
|
||||
? body.optionen.map((o) => String(o || "").trim().slice(0, 100)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (!fragestellungDe) return json(res, { ok: false, error: "Bitte eine Frage eingeben." }, 400);
|
||||
if (optionenDe.length < MIN_OPTIONEN) {
|
||||
return json(res, { ok: false, error: `Mindestens ${MIN_OPTIONEN} Antwortoptionen nötig.` }, 400);
|
||||
}
|
||||
if (optionenDe.length > MAX_OPTIONEN) {
|
||||
return json(res, { ok: false, error: `Höchstens ${MAX_OPTIONEN} Antwortoptionen erlaubt.` }, 400);
|
||||
}
|
||||
|
||||
const question = await uebersetzeAlleSprachen(fragestellungDe);
|
||||
const options = await Promise.all(optionenDe.map((o) => uebersetzeAlleSprachen(o)));
|
||||
|
||||
const id = generateId();
|
||||
const jetzt = nowIso();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_polls (id, question, options, status, created_at, created_by)
|
||||
VALUES (?, ?, ?, 'active', ?, ?)`
|
||||
).run(id, JSON.stringify(question), JSON.stringify(options), jetzt, session.actor);
|
||||
|
||||
logAction(session.actor, "polls.created", id, { frage: fragestellungDe });
|
||||
return json(res, { ok: true, poll: { id, question, options, status: "active", createdAt: jetzt, closedAt: null } });
|
||||
}
|
||||
|
||||
export async function listPolls(req, res, session) {
|
||||
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
|
||||
const rows = db.prepare(`SELECT * FROM supporter_polls ORDER BY created_at DESC`).all();
|
||||
const polls = rows.map((row) => {
|
||||
const p = parsePoll(row);
|
||||
const counts = voteCountsFor(p.id, p.options.length);
|
||||
return { ...p, counts, totalVotes: counts.reduce((a, b) => a + b, 0) };
|
||||
});
|
||||
return json(res, { ok: true, polls });
|
||||
}
|
||||
|
||||
export async function closePoll(req, res, session) {
|
||||
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.pollId || "");
|
||||
const row = db.prepare(`SELECT id, status FROM supporter_polls WHERE id = ?`).get(id);
|
||||
if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
|
||||
db.prepare(`UPDATE supporter_polls SET status = 'closed', closed_at = ? WHERE id = ?`).run(nowIso(), id);
|
||||
logAction(session.actor, "polls.closed", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function reopenPoll(req, res, session) {
|
||||
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.pollId || "");
|
||||
const row = db.prepare(`SELECT id FROM supporter_polls WHERE id = ?`).get(id);
|
||||
if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
|
||||
db.prepare(`UPDATE supporter_polls SET status = 'active', closed_at = NULL WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "polls.reopened", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deletePoll(req, res, session) {
|
||||
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.pollId || "");
|
||||
db.prepare(`DELETE FROM supporter_poll_votes WHERE poll_id = ?`).run(id);
|
||||
const info = db.prepare(`DELETE FROM supporter_polls WHERE id = ?`).run(id);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
|
||||
logAction(session.actor, "polls.deleted", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Supporter-Bereich (eigene Session, jeder aktive Supporter) ---------- */
|
||||
|
||||
export async function getActivePollPublic(req, res, supporter) {
|
||||
const row = db.prepare(`SELECT * FROM supporter_polls WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`).get();
|
||||
if (!row) return json(res, { ok: true, poll: null });
|
||||
const poll = parsePoll(row);
|
||||
const meineStimme = db
|
||||
.prepare(`SELECT option_index FROM supporter_poll_votes WHERE poll_id = ? AND supporter_id = ?`)
|
||||
.get(poll.id, supporter.id);
|
||||
const antwort = { ok: true, poll: { id: poll.id, question: poll.question, options: poll.options }, myVote: meineStimme ? meineStimme.option_index : null };
|
||||
// Ergebnisse nur zeigen, wenn schon abgestimmt wurde (sonst könnte das
|
||||
// Ergebnis die eigene Stimme beeinflussen) — dieselbe Logik wie bei
|
||||
// öffentlichen Umfrage-Tools üblich.
|
||||
if (meineStimme) {
|
||||
const counts = voteCountsFor(poll.id, poll.options.length);
|
||||
antwort.counts = counts;
|
||||
antwort.totalVotes = counts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return json(res, antwort);
|
||||
}
|
||||
|
||||
export async function voteOnPoll(req, res, supporter) {
|
||||
const pollId = String(req.body?.pollId || "");
|
||||
const optionIndex = Number(req.body?.optionIndex);
|
||||
const poll = db.prepare(`SELECT * FROM supporter_polls WHERE id = ? AND status = 'active'`).get(pollId);
|
||||
if (!poll) return json(res, { ok: false, error: "Diese Abstimmung ist nicht mehr aktiv." }, 404);
|
||||
const options = JSON.parse(poll.options);
|
||||
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
|
||||
return json(res, { ok: false, error: "Ungültige Antwortoption." }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_poll_votes (id, poll_id, supporter_id, option_index, voted_at) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(generateId(), pollId, supporter.id, optionIndex, nowIso());
|
||||
} catch (err) {
|
||||
// UNIQUE(poll_id, supporter_id) verhindert Doppelstimmen — sauber statt
|
||||
// als Serverfehler durchzureichen.
|
||||
if (String(err?.message || "").includes("UNIQUE")) {
|
||||
return json(res, { ok: false, error: "Du hast bei dieser Abstimmung bereits mitgemacht." }, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
logAction("system", "polls.voted", pollId, { supporterId: supporter.id, optionIndex });
|
||||
const counts = voteCountsFor(pollId, options.length);
|
||||
return json(res, { ok: true, counts, totalVotes: counts.reduce((a, b) => a + b, 0) });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =====================================================================
|
||||
routes/site-texts.js — admin-editierbare Kategorie-Namen & Seitentitel
|
||||
(21.08.2026, Nutzer-Wunsch: "ich will ich titel und namen von
|
||||
kategorien und seiten ändern können in der verwaltung").
|
||||
|
||||
Bewusst als generischer key->{de,"de-CH",en,fr,pt}-Override gebaut
|
||||
(wiederverwendet die vorhandene app_settings-Tabelle, key-Präfix
|
||||
"site_text_"), NICHT als eigene Tabelle -- dieselbe Technik wie
|
||||
"Event des Jahres". Ein fester, serverseitiger Katalog erlaubter
|
||||
Keys (ERLAUBTE_KEYS) verhindert, dass beliebige/kaputte Keys
|
||||
gespeichert werden können. Aktuell nur die vier Team-Kategorien +
|
||||
ihre Seitentitel belegt -- das Muster ist aber bewusst so gebaut,
|
||||
dass in einer künftigen Runde weitere Seiten/Überschriften einfach
|
||||
per neuem Key ergänzt werden können (siehe main.js:
|
||||
applyTranslations() prüft data-site-text-key genau wie data-i18n).
|
||||
|
||||
Öffentlich lesbar (jede Seite braucht das ohne Login), Schreiben nur
|
||||
mit TEAM_MANAGE (dieselbe Berechtigung wie die Team-Verwaltung,
|
||||
bewusst keine eigene -- beides gehört zusammen). ===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
// key -> { label, maxLen } -- label ist nur für Log-/Fehlermeldungen.
|
||||
const ERLAUBTE_KEYS = {
|
||||
kategorie_modi: { label: "Kategorie-Name: Modi", maxLen: 40 },
|
||||
kategorie_scout: { label: "Kategorie-Name: Scout", maxLen: 40 },
|
||||
kategorie_creator: { label: "Kategorie-Name: Creator", maxLen: 40 },
|
||||
kategorie_manager: { label: "Kategorie-Name: Manager", maxLen: 40 },
|
||||
seitentitel_modi: { label: "Seitentitel: Modi-Team", maxLen: 100 },
|
||||
seitentitel_scout: { label: "Seitentitel: Scout-Team", maxLen: 100 },
|
||||
seitentitel_creator: { label: "Seitentitel: Creator", maxLen: 100 },
|
||||
seitentitel_manager: { label: "Seitentitel: Manager", maxLen: 100 },
|
||||
};
|
||||
|
||||
function settingsKey(key) {
|
||||
return `site_text_${key}`;
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: liefert alle aktuell GESETZTEN Overrides
|
||||
als { key: {de,"de-CH",en,fr,pt} }. Keys ohne eigenen Override
|
||||
fehlen einfach im Ergebnis -- die aufrufende Seite behält dann ihren
|
||||
eingebauten Standardtext (siehe main.js). */
|
||||
export async function getSiteTextsPublic(req, res) {
|
||||
const rows = db
|
||||
.prepare(`SELECT key, value FROM app_settings WHERE key LIKE 'site_text_%'`)
|
||||
.all();
|
||||
const texte = {};
|
||||
for (const row of rows) {
|
||||
const kurzKey = row.key.replace(/^site_text_/, "");
|
||||
try {
|
||||
texte[kurzKey] = JSON.parse(row.value);
|
||||
} catch {
|
||||
/* kaputter Eintrag -> einfach überspringen, kein Fehler für alle anderen Keys */
|
||||
}
|
||||
}
|
||||
return json(res, { ok: true, texte });
|
||||
}
|
||||
|
||||
export async function saveSiteText(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const key = String(body.key || "").trim();
|
||||
const eintrag = ERLAUBTE_KEYS[key];
|
||||
if (!eintrag) return json(res, { ok: false, error: "Unbekannter Text-Schlüssel." }, 400);
|
||||
|
||||
const textDe = String(body.text || "").trim().slice(0, eintrag.maxLen);
|
||||
if (!textDe) {
|
||||
// Leeres Feld = Override entfernen -> Seite fällt zurück auf ihren
|
||||
// eingebauten Standardtext (Dogi kann so jederzeit "zurücksetzen").
|
||||
db.prepare(`DELETE FROM app_settings WHERE key = ?`).run(settingsKey(key));
|
||||
logAction(session.actor, "site_text.reset", key, null);
|
||||
return json(res, { ok: true, text: null });
|
||||
}
|
||||
|
||||
const text = await uebersetzeAlleSprachen(textDe);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(settingsKey(key), JSON.stringify(text), new Date().toISOString());
|
||||
logAction(session.actor, "site_text.saved", key, { textDe });
|
||||
return json(res, { ok: true, text });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =====================================================================
|
||||
routes/special-event.js — "Spezielles Live-Event" für streamplan.html
|
||||
(20.08.2026, Nutzer-Wunsch: "sehr wichtig ist dass ich auch spezielle
|
||||
live events da auch eintragen kann in der verwaltungsseite so dass da
|
||||
eine zweite uhr erscheint wo die zeit dann für dieses spezielle event
|
||||
läuft"). Anders als "Event des Jahres" (2 feste Slots) gibt es hier
|
||||
GENAU EIN Event mit einem exakten Zieldatum+Uhrzeit statt nur einem
|
||||
freien Datumstext — daraus lässt sich ein echter Countdown berechnen.
|
||||
Läuft auf derselben app_settings-Tabelle (key/value), kein Schema-Umbau
|
||||
nötig. Gleiche Rechte-/Freigabe-Logik wie "Event des Jahres"
|
||||
(EVENTS_MANAGE, Owner immer erlaubt, öffentlich lesbar, aber nur wenn
|
||||
aktiviert UND das Zieldatum noch in der Zukunft liegt). ===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
const SETTINGS_KEY = "special_live_event";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function laden() {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(SETTINGS_KEY);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: nur wenn aktiviert UND das Zieldatum noch
|
||||
nicht vorbei ist (ein abgelaufenes Event soll nicht ewig als "Countdown
|
||||
bei 00:00:00" hängen bleiben, sondern einfach verschwinden). */
|
||||
export async function getSpecialEventPublic(req, res) {
|
||||
const eintrag = laden();
|
||||
const istGueltig = eintrag && eintrag.aktiv && new Date(eintrag.zieldatum) > new Date();
|
||||
return json(res, { ok: true, event: istGueltig ? eintrag : null });
|
||||
}
|
||||
|
||||
/** Für die Verwaltung: liefert den rohen Stand (auch inaktiv/abgelaufen),
|
||||
damit das Formular beim Öffnen alles vorausfüllen kann. */
|
||||
export async function getSpecialEventAdmin(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
return json(res, { ok: true, event: laden() });
|
||||
}
|
||||
|
||||
export async function saveSpecialEvent(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
|
||||
const titelDe = String(body.titel || "").trim().slice(0, 120);
|
||||
const zieldatumRoh = String(body.zieldatum || "").trim();
|
||||
const link = String(body.link || "").trim().slice(0, 300) || null;
|
||||
const aktiv = !!body.aktiv;
|
||||
|
||||
if (!titelDe) return json(res, { ok: false, error: "Titel ist Pflicht." }, 400);
|
||||
|
||||
const zieldatum = new Date(zieldatumRoh);
|
||||
if (!zieldatumRoh || Number.isNaN(zieldatum.getTime())) {
|
||||
return json(res, { ok: false, error: "Bitte ein gültiges Datum + Uhrzeit angeben." }, 400);
|
||||
}
|
||||
if (link && !/^https?:\/\//i.test(link)) {
|
||||
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
|
||||
}
|
||||
if (aktiv && zieldatum <= new Date()) {
|
||||
return json(res, { ok: false, error: "Das Zieldatum liegt in der Vergangenheit — zum Aktivieren muss es in der Zukunft liegen." }, 400);
|
||||
}
|
||||
|
||||
const titel = await uebersetzeAlleSprachen(titelDe);
|
||||
|
||||
const eintrag = {
|
||||
titel,
|
||||
zieldatum: zieldatum.toISOString(),
|
||||
link,
|
||||
aktiv,
|
||||
aktualisiertVon: session.actor,
|
||||
aktualisiertAm: new Date().toISOString(),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(SETTINGS_KEY, JSON.stringify(eintrag), eintrag.aktualisiertAm);
|
||||
|
||||
logAction(session.actor, "special_event.saved", null, { titel: titelDe, zieldatum: eintrag.zieldatum, aktiv });
|
||||
return json(res, { ok: true, event: eintrag });
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/* =====================================================================
|
||||
routes/team.js — Team-Mitglieder-Verwaltung (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").
|
||||
|
||||
Update 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"):
|
||||
volle Feld-Parität mit den von Hand gepflegten Profilen (intro/bioHtml/
|
||||
extraCta) PLUS ein Import-Weg für bestehende Profile (importLegacyMember)
|
||||
-- danach lässt sich JEDES Mitglied (alt oder neu) hier bearbeiten,
|
||||
und die Website zeigt (siehe window.dogiTeamLaden() in main.js) für
|
||||
importierte slugs die DB-Version statt der statischen an.
|
||||
|
||||
WICHTIG: importLegacyMember übersetzt NICHT automatisch -- die
|
||||
bestehenden Profile haben von Hand geschriebene, geprüfte Übersetzungen
|
||||
in allen 5 Sprachen (siehe data-modis.js etc.), die dürfen beim Import
|
||||
nicht durch Maschinenübersetzung ersetzt werden. saveTeamMember (Neu
|
||||
anlegen/Bearbeiten in der Verwaltung) übersetzt weiterhin automatisch,
|
||||
weil dort nur Deutsch eingetippt wird.
|
||||
|
||||
Öffentlicher Lesezugriff (getTeamPublic) ohne Session, nur AKTIVE
|
||||
Mitglieder -- Schreibzugriff nur mit TEAM_MANAGE-Berechtigung.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
const KATEGORIEN = ["modi", "scout", "creator", "manager"];
|
||||
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function parseJsonFeld(text) {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft, ob ein vom Client geschicktes mehrsprachiges Feld wirklich ein
|
||||
Objekt mit (mindestens) einem "de"-Text ist -- genutzt vom Import, der
|
||||
KEINE eigene Übersetzung anstößt und deshalb ein vollständiges Objekt
|
||||
erwartet, kein einzelnes Deutsch-Textfeld. */
|
||||
function istGueltigesSprachFeld(x) {
|
||||
return x && typeof x === "object" && typeof x.de === "string" && x.de.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Baut aus einem client-gesendeten mehrsprachigen Feld ein sauberes,
|
||||
auf die bekannten 5 Sprachen begrenztes Objekt (verhindert, dass
|
||||
beliebige zusätzliche Keys in die Datenbank gelangen). */
|
||||
function saubereSprachen(x, maxLen) {
|
||||
const out = {};
|
||||
for (const sprache of SPRACHEN) {
|
||||
out[sprache] = String(x?.[sprache] ?? x?.de ?? "").slice(0, maxLen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Slug aus dem Namen ableiten (Kleinbuchstaben, Umlaute vereinfacht,
|
||||
alles außer a-z0-9 wird zu "-"), falls die Verwaltung keinen eigenen
|
||||
Slug mitschickt -- selbes Muster wie an anderen Stellen der Seite. */
|
||||
function slugAusName(name) {
|
||||
return String(name || "")
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
function slugFrei(slug, ausgenommeneId) {
|
||||
const row = db.prepare(`SELECT id FROM team_members WHERE slug = ?`).get(slug);
|
||||
return !row || row.id === ausgenommeneId;
|
||||
}
|
||||
|
||||
/** Macht einen Slug bei Kollision automatisch eindeutig ("-2", "-3", ...)
|
||||
statt hart abzulehnen -- Dogi/VanVan sollen nicht wegen eines
|
||||
Namensdopplers (z.B. zwei Personen mit Spitzname "Foxy") hängen
|
||||
bleiben. */
|
||||
function eindeutigerSlug(wunschSlug, ausgenommeneId) {
|
||||
if (slugFrei(wunschSlug, ausgenommeneId)) return wunschSlug;
|
||||
let zaehler = 2;
|
||||
while (!slugFrei(`${wunschSlug}-${zaehler}`, ausgenommeneId)) zaehler++;
|
||||
return `${wunschSlug}-${zaehler}`;
|
||||
}
|
||||
|
||||
function oeffentlichesFeld(row) {
|
||||
const extraCtaLabel = parseJsonFeld(row.extra_cta_label_json);
|
||||
return {
|
||||
id: row.id,
|
||||
kategorie: row.kategorie,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
rolle: parseJsonFeld(row.rolle_json),
|
||||
foto: row.foto_url || "",
|
||||
tiktok: row.tiktok_url || "",
|
||||
intro: parseJsonFeld(row.intro_json),
|
||||
bio: parseJsonFeld(row.bio_json),
|
||||
bioHtml: parseJsonFeld(row.bio_html_json),
|
||||
zitat: parseJsonFeld(row.zitat_json),
|
||||
rang: row.rang,
|
||||
rangLabel: parseJsonFeld(row.rang_label_json),
|
||||
extraCta: row.extra_cta_href && extraCtaLabel ? { href: row.extra_cta_href, label: extraCtaLabel } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function vollesFeld(row) {
|
||||
return {
|
||||
...oeffentlichesFeld(row),
|
||||
status: row.status,
|
||||
reihenfolge: row.reihenfolge,
|
||||
erstelltAm: row.erstellt_am,
|
||||
geaendertAm: row.geaendert_am,
|
||||
};
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: nur AKTIVE Mitglieder. Ohne ?kategorie=
|
||||
kommen alle vier Kategorien zusammen zurück (genutzt von profil.html,
|
||||
das vorab nicht weiß, zu welcher Kategorie ein slug gehört). */
|
||||
export async function getTeamPublic(req, res) {
|
||||
const kategorie = String(req.query?.kategorie || "").trim();
|
||||
let rows;
|
||||
if (kategorie && KATEGORIEN.includes(kategorie)) {
|
||||
rows = db
|
||||
.prepare(`SELECT * FROM team_members WHERE kategorie = ? AND status = 'aktiv' ORDER BY reihenfolge ASC, erstellt_am ASC`)
|
||||
.all(kategorie);
|
||||
} else {
|
||||
rows = db
|
||||
.prepare(`SELECT * FROM team_members WHERE status = 'aktiv' ORDER BY reihenfolge ASC, erstellt_am ASC`)
|
||||
.all();
|
||||
}
|
||||
return json(res, { ok: true, mitglieder: rows.map(oeffentlichesFeld) });
|
||||
}
|
||||
|
||||
/** Für die Verwaltung: ALLE Mitglieder aller Kategorien/Status. */
|
||||
export async function getTeamAdmin(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const rows = db.prepare(`SELECT * FROM team_members ORDER BY kategorie ASC, reihenfolge ASC, erstellt_am ASC`).all();
|
||||
return json(res, { ok: true, mitglieder: rows.map(vollesFeld) });
|
||||
}
|
||||
|
||||
/** NUR Foto-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware).
|
||||
Volle Adresse statt relativem Pfad (Bugfix 21.08.2026, siehe derselbe Fehler +
|
||||
Korrektur in routes/events.js/uploadEventImage): die Verwaltung UND die echte
|
||||
Startseite laufen auf dogfather-universe.com (dogiweb-Dienst), die hochgeladene
|
||||
Datei liegt aber tatsächlich unter postfach.dogfather-universe.com (dogiintern-
|
||||
Dienst, siehe /uploads-Static-Route in index.js) -- ein relativer Pfad hätte
|
||||
der Browser also fälschlich gegen die falsche Domain aufgelöst und ein
|
||||
kaputtes Bild-Symbol gezeigt, sowohl in der Verwaltungs-Vorschau als auch auf
|
||||
team-modis.html/team-scouts.html/creator.html/manager.html. */
|
||||
export async function uploadTeamPhoto(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
|
||||
const url = `https://postfach.dogfather-universe.com/uploads/team/${req.file.filename}`;
|
||||
logAction(session.actor, "team.photo_uploaded", req.file.filename, null);
|
||||
return json(res, { ok: true, url });
|
||||
}
|
||||
|
||||
export async function saveTeamMember(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "").trim() || null;
|
||||
|
||||
const kategorie = String(body.kategorie || "").trim();
|
||||
if (!KATEGORIEN.includes(kategorie)) {
|
||||
return json(res, { ok: false, error: "Ungültige Kategorie." }, 400);
|
||||
}
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
if (!name) return json(res, { ok: false, error: "Name ist Pflicht." }, 400);
|
||||
|
||||
const rolleDe = String(body.rolle || "").trim().slice(0, 120);
|
||||
if (!rolleDe) return json(res, { ok: false, error: "Rolle ist Pflicht." }, 400);
|
||||
|
||||
const wunschSlug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60) || slugAusName(name);
|
||||
if (!wunschSlug) return json(res, { ok: false, error: "Konnte keinen gültigen Slug ableiten -- bitte Namen prüfen." }, 400);
|
||||
const slug = eindeutigerSlug(wunschSlug, id);
|
||||
|
||||
const tiktokRoh = String(body.tiktok || "").trim().slice(0, 200);
|
||||
let tiktokUrl = null;
|
||||
if (tiktokRoh) {
|
||||
if (/^https?:\/\//i.test(tiktokRoh)) {
|
||||
tiktokUrl = tiktokRoh;
|
||||
} else {
|
||||
// Bequemlichkeit: reiner Benutzername (mit oder ohne @) wird
|
||||
// automatisch zu einer vollen TikTok-URL ergänzt.
|
||||
tiktokUrl = `https://www.tiktok.com/@${tiktokRoh.replace(/^@/, "")}`;
|
||||
}
|
||||
}
|
||||
|
||||
const introDe = String(body.intro || "").trim().slice(0, 400);
|
||||
const bioDe = String(body.bio || "").trim().slice(0, 800);
|
||||
const bioHtmlDe = String(body.bioHtml || "").trim().slice(0, 6000);
|
||||
const zitatDe = String(body.zitat || "").trim().slice(0, 200);
|
||||
const fotoUrl = String(body.foto || "").trim().slice(0, 300) || null;
|
||||
|
||||
const rang = body.rang !== undefined && body.rang !== null && body.rang !== ""
|
||||
? Math.max(1, Math.min(99, Number(body.rang) || 0)) : null;
|
||||
const rangLabelDe = String(body.rangLabel || "").trim().slice(0, 40);
|
||||
|
||||
const extraCtaHref = String(body.extraCtaHref || "").trim().slice(0, 300) || null;
|
||||
const extraCtaLabelDe = String(body.extraCtaLabel || "").trim().slice(0, 60);
|
||||
if (extraCtaHref && !/^https?:\/\//i.test(extraCtaHref) && !extraCtaHref.startsWith("/") && !extraCtaHref.endsWith(".html")) {
|
||||
return json(res, { ok: false, error: "Zweiter Button: Link muss eine Seite (…html) oder http(s)-Adresse sein." }, 400);
|
||||
}
|
||||
|
||||
const reihenfolge = Number.isFinite(Number(body.reihenfolge)) ? Number(body.reihenfolge) : 0;
|
||||
const status = body.status === "pausiert" ? "pausiert" : "aktiv";
|
||||
|
||||
const [rolle, intro, bio, bioHtml, zitat, rangLabel, extraCtaLabel] = await Promise.all([
|
||||
uebersetzeAlleSprachen(rolleDe),
|
||||
introDe ? uebersetzeAlleSprachen(introDe) : Promise.resolve(null),
|
||||
bioDe ? uebersetzeAlleSprachen(bioDe) : Promise.resolve(null),
|
||||
bioHtmlDe ? uebersetzeAlleSprachen(bioHtmlDe) : Promise.resolve(null),
|
||||
zitatDe ? uebersetzeAlleSprachen(zitatDe) : Promise.resolve(null),
|
||||
rangLabelDe ? uebersetzeAlleSprachen(rangLabelDe) : Promise.resolve(null),
|
||||
extraCtaLabelDe ? uebersetzeAlleSprachen(extraCtaLabelDe) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const jetzt = nowIso();
|
||||
const werte = {
|
||||
id: id || generateId(),
|
||||
kategorie, name, slug,
|
||||
rolle_json: JSON.stringify(rolle),
|
||||
foto_url: fotoUrl,
|
||||
tiktok_url: tiktokUrl,
|
||||
intro_json: intro ? JSON.stringify(intro) : null,
|
||||
bio_json: bio ? JSON.stringify(bio) : null,
|
||||
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
|
||||
zitat_json: zitat ? JSON.stringify(zitat) : null,
|
||||
rang, rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
|
||||
extra_cta_href: extraCtaHref && extraCtaLabel ? extraCtaHref : null,
|
||||
extra_cta_label_json: extraCtaHref && extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
|
||||
reihenfolge, status,
|
||||
geaendert_am: jetzt, geaendert_von: session.actor,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
const info = db
|
||||
.prepare(
|
||||
`UPDATE team_members SET
|
||||
kategorie=@kategorie, name=@name, slug=@slug, rolle_json=@rolle_json,
|
||||
foto_url=@foto_url, tiktok_url=@tiktok_url, intro_json=@intro_json,
|
||||
bio_json=@bio_json, bio_html_json=@bio_html_json, zitat_json=@zitat_json,
|
||||
rang=@rang, rang_label_json=@rang_label_json,
|
||||
extra_cta_href=@extra_cta_href, extra_cta_label_json=@extra_cta_label_json,
|
||||
reihenfolge=@reihenfolge, status=@status,
|
||||
geaendert_am=@geaendert_am, geaendert_von=@geaendert_von
|
||||
WHERE id=@id`
|
||||
)
|
||||
.run(werte);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "team.updated", id, { name, kategorie });
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO team_members
|
||||
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
|
||||
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
|
||||
extra_cta_href, extra_cta_label_json, reihenfolge, status,
|
||||
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
|
||||
VALUES
|
||||
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
|
||||
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
|
||||
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
|
||||
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
|
||||
).run(werte);
|
||||
logAction(session.actor, "team.created", werte.id, { name, kategorie });
|
||||
}
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
|
||||
return json(res, { ok: true, mitglied: vollesFeld(row) });
|
||||
}
|
||||
|
||||
/** Importiert ein BESTEHENDES, von Hand gepflegtes Profil (aus
|
||||
data-modis.js/data-scouts.js/data-creator.js/data-manager.js) 1:1 in
|
||||
die Datenbank -- danach ist es über die Verwaltung wie jedes andere
|
||||
Mitglied bearbeitbar, und Änderungen gelten für ALLE (siehe
|
||||
window.dogiTeamLaden()-Override-Logik in main.js).
|
||||
|
||||
WICHTIG: übersetzt NICHTS automatisch (anders als saveTeamMember) --
|
||||
erwartet für jedes Textfeld ein bereits vollständiges {de,"de-CH",en,
|
||||
fr,pt}-Objekt, wie es in den data-*.js-Dateien von Hand gepflegt
|
||||
wird. Ist ein Feld kein gültiges Sprachobjekt, wird es einfach
|
||||
weggelassen statt einen Fehler zu werfen -- ein unvollständiges
|
||||
Altprofil soll den Import nicht komplett blockieren.
|
||||
|
||||
Idempotent: ist der slug schon vorhanden, passiert nichts (kein
|
||||
Duplikat, kein Fehler) -- der Button in der Verwaltung kann beliebig
|
||||
oft geklickt werden, ohne Doppel-Einträge zu erzeugen. */
|
||||
export async function importLegacyMember(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const kategorie = String(body.kategorie || "").trim();
|
||||
if (!KATEGORIEN.includes(kategorie)) return json(res, { ok: false, error: "Ungültige Kategorie." }, 400);
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
const slug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60);
|
||||
if (!name || !slug) return json(res, { ok: false, error: "Name und Slug sind Pflicht." }, 400);
|
||||
if (!istGueltigesSprachFeld(body.rolle)) return json(res, { ok: false, error: "Rolle fehlt oder unvollständig." }, 400);
|
||||
|
||||
if (!slugFrei(slug, null)) {
|
||||
return json(res, { ok: true, uebersprungen: true, grund: "Bereits importiert." });
|
||||
}
|
||||
|
||||
const rolle = saubereSprachen(body.rolle, 120);
|
||||
const intro = istGueltigesSprachFeld(body.intro) ? saubereSprachen(body.intro, 2000) : null;
|
||||
const bio = istGueltigesSprachFeld(body.bio) ? saubereSprachen(body.bio, 800) : null;
|
||||
const bioHtml = istGueltigesSprachFeld(body.bioHtml) ? saubereSprachen(body.bioHtml, 8000) : null;
|
||||
const zitat = istGueltigesSprachFeld(body.zitat) ? saubereSprachen(body.zitat, 200) : null;
|
||||
const rangLabel = istGueltigesSprachFeld(body.rangLabel) ? saubereSprachen(body.rangLabel, 40) : null;
|
||||
const extraCtaLabel = body.extraCta?.href && istGueltigesSprachFeld(body.extraCta?.label) ? saubereSprachen(body.extraCta.label, 60) : null;
|
||||
|
||||
const jetzt = nowIso();
|
||||
const werte = {
|
||||
id: generateId(),
|
||||
kategorie, name, slug,
|
||||
rolle_json: JSON.stringify(rolle),
|
||||
foto_url: String(body.foto || "").trim().slice(0, 300) || null,
|
||||
tiktok_url: body.tiktok?.href || (typeof body.tiktok === "string" ? body.tiktok : null),
|
||||
intro_json: intro ? JSON.stringify(intro) : null,
|
||||
bio_json: bio ? JSON.stringify(bio) : null,
|
||||
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
|
||||
zitat_json: zitat ? JSON.stringify(zitat) : null,
|
||||
rang: Number.isFinite(Number(body.rang)) ? Number(body.rang) : null,
|
||||
rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
|
||||
extra_cta_href: extraCtaLabel ? body.extraCta.href : null,
|
||||
extra_cta_label_json: extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
|
||||
reihenfolge: 0,
|
||||
status: "aktiv",
|
||||
geaendert_am: jetzt, geaendert_von: session.actor,
|
||||
};
|
||||
db.prepare(
|
||||
`INSERT INTO team_members
|
||||
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
|
||||
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
|
||||
extra_cta_href, extra_cta_label_json, reihenfolge, status,
|
||||
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
|
||||
VALUES
|
||||
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
|
||||
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
|
||||
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
|
||||
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
|
||||
).run(werte);
|
||||
logAction(session.actor, "team.imported", werte.id, { name, kategorie, slug });
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
|
||||
return json(res, { ok: true, uebersprungen: false, mitglied: vollesFeld(row) });
|
||||
}
|
||||
|
||||
export async function deleteTeamMember(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.id || "");
|
||||
const info = db.prepare(`DELETE FROM team_members WHERE id = ?`).run(id);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "team.deleted", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =====================================================================
|
||||
routes/testimonials.js — "Stimmen" auf stimmen.html (20.08.2026,
|
||||
Nutzer-Wunsch: "eine richtig geile kachel ... wo die leute mit namen
|
||||
und tiktok namen mir eine nachricht schreiben können die ich in die
|
||||
verwaltung kriege, und dann kann ich die ausgewählten über die
|
||||
verwaltungsseite auf die website hinzufügen").
|
||||
|
||||
Öffentlich: jede/jeder darf eine Nachricht einreichen (kein Login
|
||||
nötig, absichtlich niedrigschwellig -- "mega persönlich zu den Fans").
|
||||
Landet als Entwurf ("eingereicht"), wird NIE automatisch veröffentlicht.
|
||||
Nur Dogi/VanVan (TESTIMONIALS_MANAGE) sehen die Warteschlange und
|
||||
entscheiden pro Eintrag: freigeben (-> erscheint auf stimmen.html),
|
||||
ablehnen oder löschen. ===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function oeffentlichesFeld(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tiktokUsername: row.tiktok_username,
|
||||
nachricht: row.nachricht,
|
||||
};
|
||||
}
|
||||
|
||||
function vollesFeld(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tiktokUsername: row.tiktok_username,
|
||||
nachricht: row.nachricht,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
entschiedenAm: row.entschieden_am,
|
||||
entschiedenVon: row.entschieden_von,
|
||||
};
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: eine Nachricht einreichen. Bewusst simpel
|
||||
gehalten (kein E-Mail-Nachweis, keine Registrierung) -- die einzige
|
||||
Schutzschicht gegen Missbrauch ist die manuelle Freigabe durch Dogi,
|
||||
kein Eintrag wird je ungesehen veröffentlicht. */
|
||||
export async function submitTestimonial(req, res) {
|
||||
const body = req.body || {};
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
const tiktokUsername = String(body.tiktokUsername || "").trim().replace(/^@/, "").slice(0, 40) || null;
|
||||
const nachricht = String(body.nachricht || "").trim().slice(0, 500);
|
||||
|
||||
if (!name) return json(res, { ok: false, error: "Bitte gib deinen Namen an." }, 400);
|
||||
if (!nachricht) return json(res, { ok: false, error: "Bitte schreib eine Nachricht." }, 400);
|
||||
if (nachricht.length < 10) return json(res, { ok: false, error: "Die Nachricht ist noch sehr kurz -- magst du noch etwas mehr schreiben?" }, 400);
|
||||
|
||||
const id = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO testimonials (id, name, tiktok_username, nachricht, status, created_at) VALUES (?, ?, ?, ?, 'eingereicht', ?)`
|
||||
).run(id, name, tiktokUsername, nachricht, nowIso());
|
||||
|
||||
logAction("anonym", "testimonial.submitted", id, { name });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: nur freigegebene Stimmen, neueste zuerst. */
|
||||
export async function getApprovedTestimonialsPublic(req, res) {
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM testimonials WHERE status = 'freigegeben' ORDER BY entschieden_am DESC LIMIT 60`)
|
||||
.all();
|
||||
return json(res, { ok: true, testimonials: rows.map(oeffentlichesFeld) });
|
||||
}
|
||||
|
||||
/** Für die Verwaltung: ALLE Einreichungen, neueste zuerst -- offene zuerst
|
||||
einsortiert, damit die Warteschlange sofort ins Auge fällt. */
|
||||
export async function getTestimonialsAdmin(req, res, session) {
|
||||
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM testimonials
|
||||
ORDER BY CASE status WHEN 'eingereicht' THEN 0 ELSE 1 END, created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return json(res, { ok: true, testimonials: rows.map(vollesFeld) });
|
||||
}
|
||||
|
||||
export async function setTestimonialStatus(req, res, session) {
|
||||
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.id || "");
|
||||
const status = String(req.body?.status || "");
|
||||
if (!["freigegeben", "abgelehnt", "eingereicht"].includes(status)) {
|
||||
return json(res, { ok: false, error: "Ungültiger Status." }, 400);
|
||||
}
|
||||
const info = db
|
||||
.prepare(`UPDATE testimonials SET status = ?, entschieden_am = ?, entschieden_von = ? WHERE id = ?`)
|
||||
.run(status, nowIso(), session.actor, id);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "testimonial.status_changed", id, { status });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteTestimonial(req, res, session) {
|
||||
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.id || "");
|
||||
const info = db.prepare(`DELETE FROM testimonials WHERE id = ?`).run(id);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "testimonial.deleted", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -230,6 +230,7 @@ export function gateMiddleware(req, res, next) {
|
||||
pfad === "/gate.html" ||
|
||||
pfad.startsWith("/assets/") ||
|
||||
pfad === "/manifest.json" ||
|
||||
pfad === "/manifest-verwaltung.json" ||
|
||||
pfad === "/sw.js" ||
|
||||
pfad === "/favicon.ico"
|
||||
) {
|
||||
|
||||
@@ -37,6 +37,60 @@ app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(gateMiddleware);
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
|
||||
Quellcode (server/, server-internal/, cloudflare-worker/) und das komplette
|
||||
.git-Verzeichnis samt Commit-Historie.
|
||||
Live nachgewiesen (mit gültigem Zugangscode, der morgen für alle wegfällt):
|
||||
/server/gate.js und /.git/config lieferten HTTP 200. Ursache: serve-static blockt per
|
||||
Voreinstellung nur Dateien, deren EIGENER Name mit einem Punkt beginnt (server/.env ->
|
||||
404 durch diese Regel) — aber NICHT rekursiv. Eine Datei wie .git/config wird trotzdem
|
||||
ausgeliefert, weil "config" selbst nicht mit einem Punkt beginnt, nur der Ordnername davor.
|
||||
Diese Sperre läuft daher bewusst unabhängig von gateMiddleware (bleibt auch nach dem
|
||||
morgigen Entfernen der Zugangsschranke wirksam) und blockt ganze Ordner/Muster statt
|
||||
einzelner Dateien, damit neue Dateien darin automatisch mitgeschützt sind. */
|
||||
const GESPERRTE_ORDNER = ["/server", "/server-internal", "/cloudflare-worker"];
|
||||
const GESPERRTE_DATEIEN = [
|
||||
"/CLAUDE.md", "/DEPLOY.md", "/wrangler.toml", "/netlify.toml",
|
||||
"/gate-worker.js", "/_headers", "/.gitignore", "/.nojekyll", "/.assetsignore",
|
||||
];
|
||||
app.use((req, res, next) => {
|
||||
const pfad = req.path;
|
||||
const hatVerstecktenOrdner = pfad
|
||||
.split("/")
|
||||
.some((teil) => teil.startsWith(".") && teil.length > 1 && teil !== ".well-known");
|
||||
const inGesperrtemOrdner = GESPERRTE_ORDNER.some((p) => pfad === p || pfad.startsWith(p + "/"));
|
||||
const istGesperrteDatei = GESPERRTE_DATEIEN.includes(pfad);
|
||||
const istBackupDatei = /\.bak(-|\.|$)/i.test(pfad);
|
||||
if (hatVerstecktenOrdner || inGesperrtemOrdner || istGesperrteDatei || istBackupDatei) {
|
||||
return res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => {
|
||||
if (err) res.status(404).send("Nicht gefunden.");
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* Bugfix 20.08.2026 (Nutzer-Report: "ich kann sie immer noch nicht runter
|
||||
laden und die leiste oben ist immer noch so groß", NACHDEM der jeweilige
|
||||
Fix schon deployed war): Express selbst schickt standardmäßig GAR KEIN
|
||||
Cache-Control mit (nur ETag/Last-Modified). Weil dogfather-universe.com
|
||||
hinter Cloudflare liegt (orange-cloud-Proxy), springt Cloudflare bei
|
||||
fehlendem Origin-Cache-Control mit seinem EIGENEN Standardwert ein
|
||||
("Browser Cache TTL", per curl bestätigt: 4 Stunden = max-age=14400) —
|
||||
ein frischer Deploy war dadurch für Besucher:innen bis zu 4 Std lang im
|
||||
eigenen Browser-Cache unsichtbar, obwohl der Server längst die neue
|
||||
Version ausliefert. Cloudflare respektiert laut Doku ein vom Origin
|
||||
gesetztes Cache-Control statt seinen Standardwert zu benutzen — deshalb
|
||||
hier explizit "no-cache" (erzwingt bei jedem Laden eine Revalidierung
|
||||
per ETag/If-None-Match, liefert bei unverändertem Inhalt ein schnelles
|
||||
304 statt die ganze Datei erneut zu senden — kein "gar nicht cachen",
|
||||
nur "nie blind auf einen alten Stand vertrauen"). */
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(SITE_DIR, { index: "index.html" }));
|
||||
|
||||
app.use((req, res) => {
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Was die Community und das Team über DogFather sagen." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-stimmen.jpg');--page-bg-mobile:url('/assets/img/bg-stimmen-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -37,27 +37,58 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Eigene Stimme einreichen (20.08.2026, Nutzer-Wunsch: "eine
|
||||
richtig geile kachel ... wo die leute mit namen und tiktok namen
|
||||
mir eine nachricht schreiben können", "das wird mega persoenlich
|
||||
zu den fans"). Landet in der Verwaltung, wird NIE automatisch
|
||||
veroeffentlicht -- siehe server-internal/routes/testimonials.js. -->
|
||||
<section class="section-tight">
|
||||
<div class="container grid grid-3">
|
||||
<div class="card">
|
||||
<p class="quote" data-i18n="st_quote_community">„Hier steht bald eine echte Stimme aus der Community. 🩵"</p>
|
||||
<p class="small muted" data-i18n="st_label_community">— Community-Mitglied</p>
|
||||
<div class="container">
|
||||
<div class="stimmen-einreichen-kachel" id="stimmen-formular-box">
|
||||
<span class="stimmen-einreichen-glanz" aria-hidden="true"></span>
|
||||
<div class="stimmen-einreichen-kopf">
|
||||
<span class="stimmen-einreichen-herz" aria-hidden="true">💌</span>
|
||||
<h2 data-i18n="st_form_titel">Deine Stimme zählt</h2>
|
||||
<p class="small" data-i18n="st_form_text">
|
||||
Was bedeutet dir Team Dogi? Schreib uns deine Geschichte — mit etwas Glück liest DogFather sie
|
||||
persönlich und stellt sie hier auf die Seite.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="quote" data-i18n="st_quote_team">„Hier steht bald eine echte Stimme aus dem Team. 🩵"</p>
|
||||
<p class="small muted" data-i18n="st_label_team">— Team-Mitglied</p>
|
||||
<form id="stimmen-formular">
|
||||
<div class="field">
|
||||
<label for="st-name" data-i18n="st_form_name_label">Dein Name</label>
|
||||
<input type="text" id="st-name" maxlength="60" required />
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="quote" data-i18n="st_quote_scout">„Hier steht bald eine echte Stimme von Scouts & Creatorn. 🩵"</p>
|
||||
<p class="small muted" data-i18n="st_label_scout">— Scout/Creator</p>
|
||||
<div class="field">
|
||||
<label for="st-tiktok" data-i18n="st_form_tiktok_label">TikTok-Name (optional)</label>
|
||||
<input type="text" id="st-tiktok" maxlength="40" placeholder="@dein-name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="st-nachricht" data-i18n="st_form_nachricht_label">Deine Nachricht</label>
|
||||
<textarea id="st-nachricht" maxlength="500" rows="4" required data-i18n-attr="placeholder:st_form_nachricht_placeholder" placeholder="Erzähl uns, was dir Team Dogi bedeutet …"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;" data-i18n="st_form_absenden">💙 Nachricht einreichen</button>
|
||||
</form>
|
||||
<p class="stimmen-erfolg" id="stimmen-erfolg" data-i18n="st_form_erfolg">Danke dir! Deine Nachricht wurde eingereicht und wird von Dogi persönlich geprüft. 🩵</p>
|
||||
<p class="stimmen-fehler" id="stimmen-fehler"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Echte Testimonials von Community, Team, Scouts und Creator sammeln (mit Einverständnis zur Veröffentlichung). Bis dahin bleiben hier Platzhalter stehen. -->
|
||||
</section>
|
||||
|
||||
<!-- ===== Echte, freigegebene Stimmen -- bleibt komplett unsichtbar, bis
|
||||
mindestens eine Nachricht in der Verwaltung freigegeben wurde (kein
|
||||
Platzhaltertext mehr). ===== -->
|
||||
<section class="section-tight" id="stimmen-echte-root" hidden>
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="st_echte_titel">Das sagt die Community</h2>
|
||||
<div class="grid grid-3" id="stimmen-echte-grid" style="margin-top:1.6rem;"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-stimmen.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-stimmen.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script src="assets/js/stimmen.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Die persönliche Geschichte hinter DogFather — Filipe, Streamer, Manager bei Spicy Media und Hundepapa von Husky Casper." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-streamer.jpg');--page-bg-mobile:url('/assets/img/bg-streamer-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -42,10 +42,21 @@
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container" style="max-width:70ch;">
|
||||
<!-- Echtes Foto von Filipe (Nutzer-Wunsch 21.08.2026: "füge das foto ... neben den
|
||||
text im screen1"), assets/img/streamer-filipe-portrait.jpg -- aus
|
||||
C:\Users\qciga\Pictures\Filipe\..., auf 1000x1500 (Original-Seitenverhältnis
|
||||
2:3) verkleinert, damit object-fit:cover das Bild komplett unbeschnitten zeigt.
|
||||
frame-gold statt frame-lila (siehe Casper-Sektion weiter unten) für optische
|
||||
Abwechslung zwischen den Bild+Text-Abschnitten der Seite. -->
|
||||
<div class="container grid grid-2" style="align-items:center;gap:2.6rem;">
|
||||
<div>
|
||||
<p data-i18n="str_intro1">Ich bin 34 Jahre alt, in Luxemburg geboren und aufgewachsen — mit portugiesischen Wurzeln und dem Temperament, das mich ausmacht. Mein bisheriger Weg führte mich durch verschiedene Orte, unterschiedliche Lebensabschnitte und Erfahrungen, die mich zu dem Menschen gemacht haben, der ich heute bin.</p>
|
||||
<p class="quote" data-i18n="str_intro_quote" style="margin-top:1.4rem;">Doch DogFather ist nicht einfach nur ein Name im Internet. DogFather ist ein Teil von mir.</p>
|
||||
</div>
|
||||
<div class="collage-photo frame-gold" style="aspect-ratio:2/3;border-radius:var(--radius);">
|
||||
<img src="assets/img/streamer-filipe-portrait.jpg" alt="Filipe alias DogFather" style="width:100%;height:100%;object-fit:cover;object-position:center top;" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -73,11 +84,28 @@
|
||||
|
||||
<section>
|
||||
<div class="container grid grid-2" style="align-items:center;">
|
||||
<div class="collage-photo frame-lila" style="aspect-ratio:auto;border-radius:var(--radius);">
|
||||
<img src="assets/img/streamer-mascot.jpg" alt="DogFather Maskottchen" style="width:100%;height:auto;display:block;" />
|
||||
<!-- Bugfix 20.08.2026 (Nutzer-Report: "der soll bissl kleiner sein und nicht
|
||||
so riesig, einfach normal wie auf der startseite"): streamer-mascot.jpg ist
|
||||
900x2812 px, also extrem hochkant. Mit aspect-ratio:auto + height:auto lief
|
||||
es in voller natürlicher Höhe und war damit gut dreimal so hoch wie die
|
||||
Textspalte daneben.
|
||||
Update 21.08.2026 (Nutzer-Report: "die größe vom bild vom husky soll dem
|
||||
text rechts nebendran angepasst werden"): der erste Deckel (460px) war zu
|
||||
knapp und ließ eine sichtbare Lücke unter dem Bild. Ein Grid-Stretch-Ansatz
|
||||
(align-items:stretch + height:100%) wurde ausprobiert, zieht bei einem so
|
||||
extremen Hochkant-Seitenverhältnis aber die ganze Zeile auf die riesige
|
||||
Eigenhöhe des Bildes hoch (Grid richtet 'auto'-Zeilen am Max-Content-Beitrag
|
||||
aus) -- daher verworfen. Stattdessen ein großzügigerer fester Deckel
|
||||
(siehe .collage-photo-kompakt in main.css, jetzt 720px statt 460px), der
|
||||
sich an der typischen Höhe der Textspalte in diesem Abschnitt orientiert. -->
|
||||
<div class="collage-photo collage-photo-kompakt frame-lila" style="border-radius:var(--radius);">
|
||||
<img src="assets/img/streamer-mascot.jpg" alt="DogFather Maskottchen" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 data-i18n="str_casper_h2">🐺 Casper – mein treuer Begleiter</h2>
|
||||
<!-- TikTok-Account der Modi-Gang: 21.08.2026 auf Nutzer-Wunsch nach team-modis.html
|
||||
umgezogen ("den knopf da weg bitte und in die modie seite zu den modis
|
||||
hinzufügen") -- passt thematisch besser dort hin, siehe dort. -->
|
||||
<p data-i18n="str_casper_1">Ein ganz besonderer Teil meines Lebens und auch meiner Community ist mein Husky <a href="casper.html"><strong>Casper</strong></a>.</p>
|
||||
<p data-i18n="str_casper_2">Casper wurde adoptiert und stammt aus einem Kriegsgebiet in der Ukraine. Trotz allem, was er erlebt hat, besitzt er ein unglaublich großes und liebevolles Herz.</p>
|
||||
<p data-i18n="str_casper_3">In vielen Dingen ist Casper genauso wie sein Papa: ruhig, lieb, manchmal völlig verpeilt und immer für Dummheiten zu haben. Doch sobald es um Menschen oder Dinge geht, die er liebt, zeigt er seine beschützende Seite.</p>
|
||||
@@ -189,7 +217,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-streamer.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-streamer.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,21 +19,87 @@
|
||||
<meta name="twitter:description" content="Wann geht DogFather live? Streamplan und Livebereich." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-streamplan.jpg');--page-bg-mobile:url('/assets/img/bg-streamplan-mobile.jpg');">
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-streamplan.jpg?v=20260821n');--page-bg-mobile:url('/assets/img/bg-streamplan-mobile.jpg?v=20260821n');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-streamplan.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-streamplan.jpg?v=20260821n" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="sp_eyebrow">Welt 1 — DogFather</span>
|
||||
<h1 data-i18n="sp_h1">Streamplan & Livebereich</h1>
|
||||
<p class="lead worlds" data-i18n="sp_lead">Wann ist DogFather live?</p>
|
||||
|
||||
<!-- Live-Radar (20.08.2026, Nutzer-Wunsch "geiler und spezieller"): 24h-Ziffernblatt
|
||||
mit festem 20-Uhr-Leuchtpunkt + echtem "Jetzt"-Zeiger, schaltet live auf Rot/Puls
|
||||
um, sonst zählt es zum nächsten 20-Uhr-Termin runter. Zweite Uhr (rechts, Magenta/
|
||||
Violett) für ein optionales "spezielles Live-Event" aus der Verwaltung -- nur
|
||||
sichtbar, wenn dort eins aktiviert ist. Siehe assets/js/streamplan.js. -->
|
||||
<div class="stream-radar-reihe">
|
||||
<div class="stream-radar-wrap">
|
||||
<div class="stream-radar" id="stream-radar">
|
||||
<div class="stream-radar-sweep" aria-hidden="true"></div>
|
||||
<svg viewBox="0 0 300 300" class="stream-radar-dial" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="radarGradTaeglich" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8fd9ea"></stop>
|
||||
<stop offset="50%" stop-color="#8b5cf6"></stop>
|
||||
<stop offset="100%" stop-color="#e8c465"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="150" cy="150" r="130" class="radar-ring-outer" stroke="url(#radarGradTaeglich)"></circle>
|
||||
<circle cx="150" cy="150" r="94" class="radar-ring-inner"></circle>
|
||||
<g id="radar-ticks"></g>
|
||||
<g id="radar-fixmarker"></g>
|
||||
<line id="radar-now-hand" class="radar-now-hand" x1="150" y1="150" x2="150" y2="50"></line>
|
||||
<circle id="radar-now-dot" class="radar-now-dot" cx="150" cy="50" r="5"></circle>
|
||||
<circle cx="150" cy="150" r="4" class="radar-center-dot"></circle>
|
||||
</svg>
|
||||
<div class="stream-radar-center">
|
||||
<span class="stream-radar-dot" id="radar-status-dot"></span>
|
||||
<span class="stream-radar-status" id="radar-status-text" data-i18n="sp_radar_loading">Lädt …</span>
|
||||
<span class="stream-radar-countdown" id="radar-countdown"></span>
|
||||
<span class="stream-radar-sub" id="radar-status-sub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-primary stream-radar-watch-btn" id="radar-watch-btn" href="https://www.tiktok.com/@dogfather0804" target="_blank" rel="noopener" hidden data-i18n="sp_radar_watch_btn">🔴 Jetzt anschauen</a>
|
||||
<p class="small muted stream-radar-hinweis" data-i18n="sp_radar_hinweis">Alle Zeiten in deiner Ortszeit.</p>
|
||||
</div>
|
||||
|
||||
<div class="stream-radar-wrap" id="se-radar-wrap" hidden>
|
||||
<div class="stream-radar event-spezial" id="se-radar">
|
||||
<div class="stream-radar-sweep" aria-hidden="true"></div>
|
||||
<svg viewBox="0 0 300 300" class="stream-radar-dial" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="radarGradSpezial" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff5cb8"></stop>
|
||||
<stop offset="50%" stop-color="#c9a6ff"></stop>
|
||||
<stop offset="100%" stop-color="#e8c465"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="150" cy="150" r="130" class="radar-ring-outer" stroke="url(#radarGradSpezial)"></circle>
|
||||
<circle cx="150" cy="150" r="94" class="radar-ring-inner"></circle>
|
||||
<g id="se-radar-ticks"></g>
|
||||
<g id="se-radar-fixmarker"></g>
|
||||
<line id="se-radar-now-hand" class="radar-now-hand" x1="150" y1="150" x2="150" y2="50"></line>
|
||||
<circle id="se-radar-now-dot" class="radar-now-dot" cx="150" cy="50" r="5"></circle>
|
||||
<circle cx="150" cy="150" r="4" class="radar-center-dot"></circle>
|
||||
</svg>
|
||||
<div class="stream-radar-center">
|
||||
<span class="stream-radar-titel" id="se-radar-titel"></span>
|
||||
<span class="stream-radar-countdown" id="se-radar-countdown"></span>
|
||||
<span class="stream-radar-sub" data-i18n="sp_radar_hinweis_spezial">Spezielles Live-Event</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-primary stream-radar-watch-btn" id="se-radar-link" href="#" target="_blank" rel="noopener" hidden data-i18n="sp_radar_mehr_erfahren">Mehr erfahren →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
<a class="btn-tiktok" href="https://www.tiktok.com/@dogfather0804" target="_blank" rel="noopener"><span class="btn-tiktok-note" aria-hidden="true">♪</span><span class="btn-tiktok-text" data-i18n="sp_btn_tiktok">Zur Dogi TikTok-Seite</span></a>
|
||||
</div>
|
||||
@@ -60,7 +126,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-streamplan.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-streamplan.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script src="assets/js/streamplan.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0b0d10" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* =====================================================================
|
||||
Supporter-Bereich — komplett neu gestaltet (Nutzer-Feedback
|
||||
@@ -42,6 +42,22 @@
|
||||
.abo-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .5rem; display: none; }
|
||||
.abo-erfolg.zeige { display: block; }
|
||||
|
||||
/* Supporter-Abstimmungen (20.08.2026) — im selben Gold-Ton wie der Rest
|
||||
des Supporter-Bereichs, keine eigene Farbwelt. */
|
||||
.poll-option-btn {
|
||||
display: block; width: 100%; text-align: left; padding: .8rem 1rem; margin-bottom: .6rem;
|
||||
border-radius: 10px; border: 1.5px solid var(--border); background: rgba(255,255,255,.03);
|
||||
color: var(--text); font: inherit; cursor: pointer; transition: border-color .15s ease, background .15s ease;
|
||||
}
|
||||
.poll-option-btn:hover { border-color: var(--accent-2); }
|
||||
.poll-option-btn.gewaehlt { border-color: var(--accent-2); background: rgba(212,175,55,.14); font-weight: 700; }
|
||||
.poll-result-row { margin-bottom: .8rem; }
|
||||
.poll-result-row:last-child { margin-bottom: 0; }
|
||||
.poll-result-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
|
||||
.poll-result-head.meine-stimme { color: #ffe9a8; font-weight: 800; }
|
||||
.poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
|
||||
.poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .4s ease; }
|
||||
|
||||
/* Gemeinsamer Karten-Rahmen mit Gold-Schimmerlinie oben — dieselbe
|
||||
Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich
|
||||
erkennbar zusammengehört. */
|
||||
@@ -323,6 +339,18 @@
|
||||
<div class="container">
|
||||
<div id="supp-kuendigungs-hinweis" class="supp-hinweis" style="display:none;"></div>
|
||||
|
||||
<!-- Supporter-Abstimmungen (20.08.2026) — nur sichtbar, wenn gerade
|
||||
eine Abstimmung aktiv ist bzw. noch nicht abgestimmt wurde, siehe
|
||||
ladePoll() weiter unten. -->
|
||||
<div class="card supp-card" id="supp-poll-card" style="display:none;margin-bottom:2.4rem;">
|
||||
<span class="eyebrow" data-i18n="su_poll_eyebrow">🗳️ Aktuelle Abstimmung</span>
|
||||
<h3 id="poll-frage-text" style="margin:.5rem 0 1.1rem;">–</h3>
|
||||
<div id="poll-optionen-container"></div>
|
||||
<button type="button" class="btn btn-primary" id="poll-abstimmen-btn" style="margin-top:.4rem;" data-i18n="su_poll_btn_abstimmen">Abstimmen</button>
|
||||
<p class="abo-erfolg" id="poll-erfolg" data-i18n="su_poll_danke">Danke, deine Stimme wurde gezählt! 💙</p>
|
||||
<p class="abo-fehler" id="poll-fehler"></p>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
|
||||
<div class="card supp-card">
|
||||
<div class="supp-stats">
|
||||
@@ -388,9 +416,9 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-supporter.js"></script>
|
||||
<script src="assets/js/supporter.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-supporter.js?v=20260821n"></script>
|
||||
<script src="assets/js/supporter.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const S = window.DogiSupporter;
|
||||
@@ -600,8 +628,108 @@
|
||||
// Profil-Formular vorbefüllen
|
||||
document.getElementById("edit-name").value = data.profile.displayName || "";
|
||||
document.getElementById("edit-tiktok").value = data.profile.tiktokUsername || "";
|
||||
|
||||
// Supporter-Abstimmungen (20.08.2026) — nur mit echtem Token abrufen,
|
||||
// nicht im Vorschau-Modus mit Beispieldaten (kein API-Aufruf gewollt).
|
||||
if (!nurMockNoetig) await ladePoll();
|
||||
else document.getElementById("supp-poll-card").style.display = "none";
|
||||
}
|
||||
|
||||
/* ---------- Supporter-Abstimmungen (20.08.2026) ----------
|
||||
Zeigt die aktuell aktive Abstimmung (falls vorhanden). Vor der eigenen
|
||||
Stimme: nur die Antwortoptionen zum Anklicken, keine Ergebnisse (damit
|
||||
sie die eigene Wahl nicht beeinflussen). Nach der Stimme (auch bei
|
||||
einem erneuten Seitenaufruf, dank myVote vom Server): Ergebnisbalken
|
||||
statt Auswahl-Buttons. */
|
||||
const feldSprache = (mehrsprachigesFeld) =>
|
||||
window.dogiFeld ? window.dogiFeld(mehrsprachigesFeld) : mehrsprachigesFeld?.de || "";
|
||||
|
||||
let aktuellePoll = null;
|
||||
let gewaehlteOption = null;
|
||||
|
||||
function renderPollErgebnisse(poll, counts, totalVotes, meineOption) {
|
||||
const container = document.getElementById("poll-optionen-container");
|
||||
container.innerHTML = poll.options
|
||||
.map((opt, i) => {
|
||||
const n = counts?.[i] || 0;
|
||||
const proz = totalVotes ? Math.round((n / totalVotes) * 100) : 0;
|
||||
const istMeine = i === meineOption;
|
||||
return `<div class="poll-result-row">
|
||||
<div class="poll-result-head${istMeine ? " meine-stimme" : ""}">
|
||||
<span>${istMeine ? "✓ " : ""}${escapeHtmlPoll(feldSprache(opt))}</span>
|
||||
<span>${n} ${escapeHtmlPoll(feldSprache(window.I18N_PAGE?.su_poll_stimmen))} · ${proz}%</span>
|
||||
</div>
|
||||
<div class="poll-bar-track"><div class="poll-bar-fill" style="width:${proz}%;"></div></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
document.getElementById("poll-abstimmen-btn").style.display = "none";
|
||||
}
|
||||
|
||||
function escapeHtmlPoll(text) {
|
||||
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
||||
}
|
||||
|
||||
function renderPollAuswahl(poll) {
|
||||
const container = document.getElementById("poll-optionen-container");
|
||||
gewaehlteOption = null;
|
||||
container.innerHTML = poll.options
|
||||
.map((opt, i) => `<button type="button" class="poll-option-btn" data-option-index="${i}">${escapeHtmlPoll(feldSprache(opt))}</button>`)
|
||||
.join("");
|
||||
container.querySelectorAll(".poll-option-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
gewaehlteOption = Number(btn.dataset.optionIndex);
|
||||
container.querySelectorAll(".poll-option-btn").forEach((b) => b.classList.toggle("gewaehlt", b === btn));
|
||||
});
|
||||
});
|
||||
document.getElementById("poll-abstimmen-btn").style.display = "";
|
||||
}
|
||||
|
||||
async function ladePoll() {
|
||||
const karte = document.getElementById("supp-poll-card");
|
||||
let res;
|
||||
try {
|
||||
res = await S.apiFetch("/supporter/polls/active", {}, true);
|
||||
} catch {
|
||||
karte.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!res.poll) {
|
||||
karte.style.display = "none";
|
||||
return;
|
||||
}
|
||||
aktuellePoll = res.poll;
|
||||
karte.style.display = "";
|
||||
document.getElementById("poll-frage-text").textContent = feldSprache(res.poll.question);
|
||||
document.getElementById("poll-erfolg").classList.remove("zeige");
|
||||
document.getElementById("poll-fehler").classList.remove("zeige");
|
||||
|
||||
if (res.myVote !== null && res.myVote !== undefined) {
|
||||
renderPollErgebnisse(res.poll, res.counts, res.totalVotes, res.myVote);
|
||||
document.getElementById("poll-erfolg").classList.add("zeige");
|
||||
} else {
|
||||
renderPollAuswahl(res.poll);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("poll-abstimmen-btn").addEventListener("click", async () => {
|
||||
const fehlerEl = document.getElementById("poll-fehler");
|
||||
fehlerEl.classList.remove("zeige");
|
||||
if (gewaehlteOption === null || !aktuellePoll) {
|
||||
fehlerEl.textContent = feldSprache(window.I18N_PAGE?.su_poll_bitte_waehlen) || "Bitte wähle eine Antwort aus.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await S.apiFetch("/supporter/polls/vote", { pollId: aktuellePoll.id, optionIndex: gewaehlteOption }, true);
|
||||
renderPollErgebnisse(aktuellePoll, res.counts, res.totalVotes, gewaehlteOption);
|
||||
document.getElementById("poll-erfolg").classList.add("zeige");
|
||||
} catch (err) {
|
||||
fehlerEl.textContent = err.message || "Abstimmen hat leider nicht geklappt. Bitte versuch es nochmal.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Detail-Modals für die klickbaren Kennzahl-Kärtchen ----------
|
||||
Nutzer-Wunsch 04.08.2026: "wenn man auf diese 2 Kisten drückt, sollen
|
||||
die viel mehr Infos kriegen ... richtig geil". Recherche, was für
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="VanVan, Diene, Funny, Miss und Marina — das Modi-/Core-Team von Team Dogi." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -32,8 +32,14 @@
|
||||
<div class="hero-banner"><img src="/assets/img/bg-dogfather.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="tm_eyebrow">🩵 Team Dogi</span>
|
||||
<h1 data-i18n="tm_h1">Das Modi-Team</h1>
|
||||
<h1 data-i18n="tm_h1" data-site-text-key="seitentitel_modi">Das Modi-Team</h1>
|
||||
<p class="lead worlds" data-i18n="tm_lead">Das Rückgrat von Team Dogi — von VanVan bis Marina.</p>
|
||||
<!-- TikTok-Account der Modi-Gang (Nutzer-Wunsch 20.08.2026, umgezogen 21.08.2026 von
|
||||
streamer.html hierher: "in die modie seite zu den modis hinzufügen"). Passt hier
|
||||
thematisch besser hin, weil der Account das ganze Modi-Team repräsentiert. -->
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
<a class="btn-tiktok" href="https://www.tiktok.com/@dogis.modi.gang" target="_blank" rel="noopener"><span class="btn-tiktok-note" aria-hidden="true">♪</span><span class="btn-tiktok-text" data-i18n="tm_btn_tiktok_modigang">Zur Modi-Gang TikTok-Seite</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -66,24 +72,31 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-modis.js"></script>
|
||||
<script src="assets/js/i18n-team-modis.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-modis.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-team-modis.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const grid = document.getElementById("modis-grid");
|
||||
if (!grid || !window.MODIS) return;
|
||||
const rest = window.MODIS.filter((m) => m.name !== "VanVan").sort((a, b) => a.rank - b.rank);
|
||||
|
||||
const feld = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || "");
|
||||
const t = (schluessel) => (window.dogiUebersetzen ? window.dogiUebersetzen(schluessel) : schluessel);
|
||||
|
||||
const MAX_STUFEN = 5; // fünf definierte Größenstufen im CSS
|
||||
|
||||
// 21.08.2026: rest/raenge werden jetzt INNERHALB von renderModis() frisch aus
|
||||
// window.MODIS berechnet (statt einmalig beim Setup) -- nötig, damit über die
|
||||
// Verwaltung nachträglich angehängte Modis (siehe dogiTeamLaden weiter unten)
|
||||
// beim erneuten Aufruf von renderModis() tatsächlich mit auftauchen. Ein
|
||||
// admin-gepflegter Modi ohne eigenen Rang fällt dabei (?? 999) ganz ans Ende
|
||||
// der Pyramide, statt die Sortierung der bestehenden Ränge durcheinander zu
|
||||
// bringen.
|
||||
const renderModis = () => {
|
||||
const rest = window.MODIS.filter((m) => m.name !== "VanVan").sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
|
||||
// Pyramide: eine Ebene pro eindeutigem Rang, oben schmal/groß, nach
|
||||
// unten breiter/kompakter — genau wie ein Organigramm.
|
||||
const raenge = [...new Set(rest.map((m) => m.rank))];
|
||||
const MAX_STUFEN = 5; // fünf definierte Größenstufen im CSS
|
||||
|
||||
const renderModis = () => {
|
||||
grid.innerHTML = raenge
|
||||
.map((rang, i) => {
|
||||
const stufe = Math.min(i + 1, MAX_STUFEN);
|
||||
@@ -119,6 +132,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
renderModis();
|
||||
document.addEventListener("dogi-sprache-geaendert", renderModis);
|
||||
|
||||
// 21.08.2026: über die Verwaltung angelegte/importierte Modis einmischen
|
||||
// (+ Runde 2: "wenn ich eine änderung mache ist es auch für jeden").
|
||||
// Rang/Rang-Bezeichnung kommen mit, damit sie sich sauber in die
|
||||
// bestehende Pyramide einordnen. dogiTeamZusammenfuehren() überschreibt
|
||||
// bereits importierte Modis (gleicher slug) mit ihrem aktuellen DB-Stand.
|
||||
if (window.dogiTeamLaden) {
|
||||
window.dogiTeamLaden("modi").then((neue) => {
|
||||
if (!neue.length) return;
|
||||
window.MODIS = window.dogiTeamZusammenfuehren(window.MODIS, neue);
|
||||
renderModis();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Die Scouts von Team Dogi bei Spicy Media." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-scouts.jpg');--page-bg-mobile:url('/assets/img/bg-scouts-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="hero-banner"><img src="/assets/img/bg-scouts.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="ts_eyebrow">Manager DogFather — Spicy Media</span>
|
||||
<h1 data-i18n="ts_h1">Mein Scout-Team</h1>
|
||||
<h1 data-i18n="ts_h1" data-site-text-key="seitentitel_scout">Mein Scout-Team</h1>
|
||||
<p class="lead worlds" data-i18n="ts_lead">Sie finden und begleiten neue Talente für Dogi.</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -49,9 +49,9 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/data-scouts.js"></script>
|
||||
<script src="assets/js/i18n-team-scouts.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/data-scouts.js?v=20260821n"></script>
|
||||
<script src="assets/js/i18n-team-scouts.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const grid = document.getElementById("scouts-grid");
|
||||
@@ -76,6 +76,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
renderScouts();
|
||||
document.addEventListener("dogi-sprache-geaendert", renderScouts);
|
||||
|
||||
// 21.08.2026: über die Verwaltung angelegte/importierte Scouts einmischen
|
||||
// (Nutzer-Wunsch "überall wo ... Scouts ... sind, will ich dass ich die
|
||||
// easy über meine Verwaltungsseite hinzufüge" + Runde 2: "wenn ich eine
|
||||
// änderung mache ist es auch für jeden"). dogiTeamZusammenfuehren()
|
||||
// überschreibt dabei bereits importierte Scouts (gleicher slug) mit ihrem
|
||||
// aktuellen DB-Stand statt sie zu duplizieren.
|
||||
if (window.dogiTeamLaden) {
|
||||
window.dogiTeamLaden("scout").then((neue) => {
|
||||
if (!neue.length) return;
|
||||
window.SCOUTS = window.dogiTeamZusammenfuehren(window.SCOUTS, neue);
|
||||
renderScouts();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Eine persönliche Widmung von Dogi an Tili — seine TikTok-Muddi, die erste und bis heute einzige Streamerin, die ihm all die Zeit treu geblieben ist." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* Gleiche Anpassung wie vanvan.html: der site-weit etwas größere
|
||||
Fließtext wirkt bei einem so langen persönlichen Brief zu wuchtig —
|
||||
@@ -153,7 +153,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-tt-muddi.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-tt-muddi.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="VanVan erzählt ihre Geschichte: wie sie zu Team Dogi kam, wer sie ist und was DogFather und Team Dogi ihr bedeuten." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
<style>
|
||||
/* Auf Wunsch: der site-weit vergrößerte Fließtext wirkte auf VanVans
|
||||
eigener Seite zu groß (viel Text am Stück) — hier gezielt wieder
|
||||
@@ -175,7 +175,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-vanvan.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-vanvan.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Die Werte, nach denen DogFather als Manager bei Spicy Media arbeitet." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-werte.jpg');--page-bg-mobile:url('/assets/img/bg-werte-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -113,7 +113,7 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-werte.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-werte.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<meta name="twitter:description" content="Die Geschichte von DogFather in einer interaktiven Zeitleiste." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<link rel="stylesheet" href="assets/css/main.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-zeitreise.jpg');--page-bg-mobile:url('/assets/img/bg-zeitreise-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
@@ -39,25 +39,45 @@
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<!-- Nutzer-Wunsch 20.08.2026: gleich am Anfang gut sichtbar zeigen, dass
|
||||
diese Zeitreise noch aktiv wächst/nicht fertig ist — bewusst NICHT
|
||||
die alte .todo-note-Optik (die war für Entwickler gedacht und wurde
|
||||
am 31.07.2026 sitewide unsichtbar gemacht), sondern ein einladender,
|
||||
für Besucher gestalteter Hinweis im Wachstums-Baum-Thema dieser
|
||||
Seite (passt zu 🌱 "Der Anfang" oben und 🌳 "...wächst weiter" unten). -->
|
||||
<div class="zeit-baustelle">
|
||||
<span class="zeit-baustelle-icon" aria-hidden="true">🌱</span>
|
||||
<div>
|
||||
<strong data-i18n="zr_baustelle_h3">Diese Zeitreise wächst noch</strong>
|
||||
<p data-i18n="zr_baustelle_p">Wir ergänzen laufend weitere Kapitel, Daten und Momente aus der Geschichte von DogFather & Team Dogi — schau gern öfter mal wieder vorbei.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-baum">
|
||||
<div class="zeit-baum-kappe">
|
||||
<span aria-hidden="true">🌱</span>
|
||||
<span data-i18n="zr_baum_start">Der Anfang</span>
|
||||
</div>
|
||||
|
||||
<!-- Nutzer-Wunsch 20.08.2026: echtes Datum nachgetragen (24. Oktober 2023) --
|
||||
bleibt weiterhin die erste Karte, da es das früheste Datum der ganzen
|
||||
Zeitleiste ist. -->
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<div class="zeit-datum" data-i18n="zr_ev1_date">24. Oktober 2023</div>
|
||||
<h3 data-i18n="zr_ev1_h3">Start als Content Creator</h3>
|
||||
<p data-i18n="zr_ev1_p">DogFather beginnt seine Reise auf TikTok.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutzer-Wunsch 20.08.2026: echtes Datum nachgetragen (Anfang 2025) — bleibt
|
||||
an dieser Stelle, da "Anfang 2025" chronologisch weiterhin vor "Ostern
|
||||
2025" (nächste Karte) liegt. -->
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<div class="zeit-datum" data-i18n="zr_ev2_date">Anfang 2025</div>
|
||||
<h3 data-i18n="zr_ev2_h3">Team Dogi entsteht</h3>
|
||||
<p data-i18n="zr_ev2_p">Aus einzelnen Zuschauern wird eine starke Gemeinschaft.</p>
|
||||
</div>
|
||||
@@ -72,6 +92,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutzer-Wunsch 20.08.2026: echtes Datum nachgetragen (November 2025) und
|
||||
an die chronologisch richtige Stelle verschoben (lag vorher weiter unten
|
||||
unter "Datum folgt"). -->
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev13_date">November 2025</div>
|
||||
<h3 data-i18n="zr_ev13_h3">Das Buch „HasiDog & Casper"</h3>
|
||||
<p data-i18n="zr_ev13_p">Die Geschichte von DogFather, HasiDog und seinem Husky Casper wird zum Buch.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutzer-Wunsch 20.08.2026: echtes Datum nachgetragen (1. Januar 2026) und
|
||||
an die chronologisch richtige Stelle verschoben (lag vorher weiter unten
|
||||
unter "Datum folgt"). -->
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev14_date">1. Januar 2026</div>
|
||||
<h3 data-i18n="zr_ev14_h3">HasiDog startet auf Spotify</h3>
|
||||
<p data-i18n="zr_ev14_p">HasiDog erhält sein eigenes Musikprojekt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast meilenstein">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
@@ -81,15 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev5_date">Mai 2026</div>
|
||||
<h3 data-i18n="zr_ev5_h3">Ein neues Kapitel beginnt</h3>
|
||||
<p data-i18n="zr_ev5_p">DogFather beendet die Zusammenarbeit mit seiner bisherigen Agentur.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
@@ -108,12 +143,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<!-- Nutzer-Wunsch 21.08.2026: neue Station, chronologisch nach "Juli 2026 —
|
||||
Start als Manager" (kein genauer Tag) und vor "27. Juli bis 2. August 2026 —
|
||||
Creator Cup" einsortiert, weil 25. Juli das genauere Datum ist. Als
|
||||
Meilenstein markiert -- zwei besondere Premieren an einem Tag (erstes
|
||||
TiliDog-Fan-Treffen UND erstes persönliches Treffen von DogFather und Tili). -->
|
||||
<div class="zeit-ast meilenstein">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev8_date">Juli 2026</div>
|
||||
<h3 data-i18n="zr_ev8_h3">Das Manager-Team wächst</h3>
|
||||
<p data-i18n="zr_ev8_p">Mit VanVan und Diene entsteht eine klare Teamstruktur.</p>
|
||||
<div class="zeit-datum" data-i18n="zr_ev21_date">25. Juli 2026</div>
|
||||
<h3 data-i18n="zr_ev21_h3">Erstes Fan-Treffen: Team TiliDog</h3>
|
||||
<p data-i18n="zr_ev21_p">In Wuppertal kommt die Community von Dogi & Tili zum allerersten Mal persönlich zusammen – ein richtig mega Tag voller guter Laune. Es ist gleichzeitig etwas ganz Besonderes: das erste Aufeinandertreffen von DogFather und Tili überhaupt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +162,7 @@
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev9_date">27. Juli bis 2. August 2026</div>
|
||||
<h3 data-i18n="zr_ev9_h3">Der Creator Cup</h3>
|
||||
<p data-i18n="zr_ev9_p">Gemeinsam mit Cidem organisiert DogFather den Creator Cup.</p>
|
||||
<p data-i18n="zr_ev9_p">Gemeinsam mit Cidgem organisiert DogFather den Creator Cup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,21 +193,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<!-- Reihenfolge getauscht (Nutzer-Wunsch 20.08.2026): der Website-Start hat
|
||||
ein konkretes Datum (21.08.2026) und steht deshalb VOR der Teddy-
|
||||
Kooperation, deren Datum noch offen ist ("Datum folgt") -- so bleibt die
|
||||
Zeitleiste durchgehend chronologisch lesbar. -->
|
||||
<div class="zeit-ast meilenstein">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev13_h3">Das Buch „HasiDog & Casper"</h3>
|
||||
<p data-i18n="zr_ev13_p">Die Geschichte von DogFather, HasiDog und seinem Husky Casper wird zum Buch.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev14_h3">HasiDog startet auf Spotify</h3>
|
||||
<p data-i18n="zr_ev14_p">HasiDog erhält sein eigenes Musikprojekt.</p>
|
||||
<div class="zeit-datum" data-i18n="zr_ev19_date">21. August 2026, 21 Uhr</div>
|
||||
<h3 data-i18n="zr_ev19_h3">DOGFATHER UNIVERSE geht online</h3>
|
||||
<p data-i18n="zr_ev19_p">Die offizielle Website öffnet ihre Türen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,42 +215,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev16_h3">Die ersten betreuten Creator</h3>
|
||||
<p data-i18n="zr_ev16_p">Die ersten Creator schließen sich dem Team von Manager DogFather an.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev17_h3">Weitere Scouts und Manager kommen hinzu</h3>
|
||||
<p data-i18n="zr_ev17_p">Das Team soll langfristig weiter wachsen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_datum_folgt">Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev18_h3">Das erste Community-Treffen</h3>
|
||||
<p data-i18n="zr_ev18_p">Team Dogi begegnet sich nicht mehr nur im Livestream oder auf Discord.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast meilenstein">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
<div class="zeit-datum" data-i18n="zr_ev19_date">2026 – Datum folgt</div>
|
||||
<h3 data-i18n="zr_ev19_h3">DOGFATHER UNIVERSE geht online</h3>
|
||||
<p data-i18n="zr_ev19_p">Die offizielle Website öffnet ihre Türen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zeit-ast meilenstein zeit-final">
|
||||
<div class="zeit-node" aria-hidden="true"></div>
|
||||
<div class="zeit-karte">
|
||||
@@ -238,8 +237,8 @@
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-zeitreise.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script src="assets/js/i18n-zeitreise.js?v=20260821n"></script>
|
||||
<script src="assets/js/main.js?v=20260821n"></script>
|
||||
<script>
|
||||
/* Wachstums-Reveal: Äste blenden sich beim Scrollen sanft ein — passt
|
||||
zum "wachsenden Baum"-Konzept dieser Seite. Rein page-lokal, betrifft
|
||||
|
||||