Der Tastatur-Test (pruef-tastatur.mjs, neu) zeigte auf allen fünf geprüften Seiten dasselbe Bild: jedes Bedienelement erreichbar, Fokus durchgehend sichtbar, keine Tastaturfalle -- aber kein Sprunglink. Ohne ihn muss sich jemand, der die Tastatur benutzt, auf JEDER Seite erneut durch das komplette Menü tabben (31 bis 52 Punkte), bevor der eigentliche Inhalt beginnt. WCAG 2.4.1 verlangt genau diesen Ausweg. An einer Stelle gelöst statt in 35 Dateien: renderHeader() in main.js setzt das Sprungziel und stellt den Link davor. Das tabindex="-1" am <main> ist der Teil, der meistens fehlt: ohne ihn verschiebt der Sprung in Chrome und Safari nur die Bildlaufleiste, der Tastaturfokus bleibt in der Navigation -- der nächste Tab landet wieder im Menü und der Sprung war wirkungslos. Cache-Buster auf 20260827a (244 Stellen), sonst bekäme niemand die geänderte main.js und main.css ausgeliefert. Co-Authored-By: Claude Opus 5 <[email protected]>
1436 lines
72 KiB
JavaScript
1436 lines
72 KiB
JavaScript
/* =====================================================================
|
|
DOGFATHER UNIVERSE — main.js
|
|
Gemeinsame Navigation, mobiles Menü, Footer, Nebel-Effekt.
|
|
Läuft rein clientseitig (kein Build-Tool, kein Fetch von Partials),
|
|
damit jede Seite auch direkt per Doppelklick (file://) funktioniert.
|
|
===================================================================== */
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ===================================================================
|
|
Mehrsprachigkeit (i18n)
|
|
|
|
Fünf Sprachen: Deutsch (Standard, IMMER die Sprache beim ersten
|
|
Besuch), Englisch, echtes Schweizerdeutsch (Dialekt, nicht nur
|
|
Hochdeutsch mit ß→ss — Wunsch von Dogi; da Dialekt keine einheitliche
|
|
Schriftform hat, wurde eine gängige, Zürich-nahe Schreibweise
|
|
gewählt), Französisch, Portugiesisch. Rein clientseitig, kein
|
|
Build-Tool:
|
|
- I18N_CORE deckt die sitewide gemeinsamen Textstücke ab (Navigation,
|
|
Fußzeile, Musik-Widget) — läuft auf JEDER Seite, weil main.js
|
|
überall eingebunden ist.
|
|
- Seiteneigene Inhalte (z.B. die Startseite) bekommen zusätzlich ein
|
|
eigenes Wörterbuch über window.I18N_PAGE (siehe assets/js/i18n-*.js),
|
|
das per data-i18n="schluessel" auf Elemente gemappt wird.
|
|
Markennamen (DogFather, HasiDog, Casper, VanVan, Team Dogi,
|
|
Modi-Team, Scout-Team, Streamer, Creator, Manager, Community) bleiben
|
|
bewusst in JEDER Sprache unübersetzt — das ist die Marke.
|
|
Gewählte Sprache landet in localStorage und bleibt beim Klicken durch
|
|
die Seite erhalten. Ein komplett neuer Besuch (keine Speicherung)
|
|
startet immer auf Deutsch — nie automatische Browsersprache-Erkennung.
|
|
=================================================================== */
|
|
/* Bewusst KEINE Flaggen-Emojis: Windows rendert Flaggen-Emojis
|
|
grundsätzlich nicht als Bild, sondern als nackten Buchstaben-Code
|
|
(z.B. "DE") — zusammen mit dem eigenen Kürzel wäre das "DE DE",
|
|
"GB EN" (Flagge≠Sprachcode bei Englisch) usw. Ein einzelnes
|
|
Globus-Symbol im Knopf plus volle Sprachnamen im Dropdown ist
|
|
eindeutig und funktioniert auf jedem Betriebssystem gleich. */
|
|
/* Pro Sprache eine kräftige, gut leuchtende Akzentfarbe (glut) — für
|
|
Schrift-Leuchten, Knopf-Aurora und die glänzende Kugel (deren
|
|
Farbverlauf wird rein in CSS aus dieser einen Farbe gebaut, siehe
|
|
.sprach-kugel in main.css). Angelehnt an die jeweilige Landesfarbe,
|
|
aber bewusst gesättigter/heller (nicht die originalen, oft eher
|
|
stumpfen Flaggenfarben), damit der Polarlicht-Effekt gut trägt. */
|
|
const SPRACHEN = [
|
|
// "glut" = einzelne Leuchtfarbe (Text-Glow, Dropdown-Zeile).
|
|
// "farbstopps" = mehrere Landesfarben, aus denen Knopf-Aurora und
|
|
// Kugel-Verlauf gemischt werden — daher wirklich "gemischte Farben
|
|
// wie bei den jeweiligen Nationalflaggen", nicht nur ein Farbton.
|
|
{ code: "de", kurz: "DE", name: "Deutsch", glut: "#ffb300", farbstopps: ["#1a1a1a", "#dd0000", "#ffce00"] },
|
|
{ code: "de-CH", kurz: "CH", name: "Schwiizerdütsch", glut: "#ff3b3b", farbstopps: ["#d52b1e", "#ffffff", "#d52b1e"] },
|
|
{ code: "en", kurz: "EN", name: "English", glut: "#2f6bff", farbstopps: ["#00247d", "#ffffff", "#cf142b"] },
|
|
{ code: "fr", kurz: "FR", name: "Français", glut: "#00c2ff", farbstopps: ["#0055a4", "#ffffff", "#ef4135"] },
|
|
{ code: "pt", kurz: "PT", name: "Português", glut: "#20d97a", farbstopps: ["#046a38", "#ffcc00", "#da291c"] },
|
|
];
|
|
const SPRACHE_SCHLUESSEL = "dogi-sprache";
|
|
|
|
function getSprache() {
|
|
const gespeichert = localStorage.getItem(SPRACHE_SCHLUESSEL);
|
|
return SPRACHEN.some((s) => s.code === gespeichert) ? gespeichert : "de";
|
|
}
|
|
|
|
function setSprache(code) {
|
|
if (!SPRACHEN.some((s) => s.code === code)) return;
|
|
localStorage.setItem(SPRACHE_SCHLUESSEL, code);
|
|
document.documentElement.setAttribute("lang", code);
|
|
renderHeader();
|
|
renderFooter();
|
|
applyTranslations();
|
|
applyMusicWidgetSprache();
|
|
// Seiteneigene Skripte (z.B. der Live-Status auf index.html) können
|
|
// darauf reagieren, um dynamisch gesetzten Text (nicht über
|
|
// data-i18n) ebenfalls neu zu übersetzen.
|
|
document.dispatchEvent(new CustomEvent("dogi-sprache-geaendert", { detail: { sprache: code } }));
|
|
}
|
|
|
|
const I18N_CORE = {
|
|
/* Steht als Erstes im Wörterbuch, weil es als Erstes angesprungen wird:
|
|
der Sprung über die Navigation direkt zum Inhalt. Sichtbar nur, wenn
|
|
er den Tastaturfokus hat -- mit der Maus bekommt ihn niemand zu sehen.
|
|
Ohne ihn muss man sich auf JEDER Seite erneut durch das komplette
|
|
Menü tabben (über 20 Punkte), bevor der eigentliche Text beginnt. */
|
|
nav_zum_inhalt: { de: "Zum Inhalt springen", "de-CH": "Zum Inhalt springe", en: "Skip to content", fr: "Aller au contenu", pt: "Ir para o conteúdo" },
|
|
nav_start: { de: "Start", "de-CH": "Start", en: "Home", fr: "Accueil", pt: "Início" },
|
|
/* 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" },
|
|
/* 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: "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" },
|
|
nav_hasidog_streamplan: { de: "Streamplan", "de-CH": "Streamplan", en: "Stream schedule", fr: "Programme des streams", pt: "Programação das lives" },
|
|
nav_manager: { de: "Manager", "de-CH": "Manager", en: "Manager", fr: "Manager", pt: "Manager" },
|
|
nav_manager_dogfather: { de: "Manager DogFather", "de-CH": "Manager DogFather", en: "Manager DogFather", fr: "Manager DogFather", pt: "Manager DogFather" },
|
|
nav_manager_scouts: { de: "Scout-Team", "de-CH": "Scout-Team", en: "Scout Team", fr: "Équipe Scouts", pt: "Equipe Scouts" },
|
|
nav_manager_creator: { de: "Creator", "de-CH": "Creator", en: "Creator", fr: "Creator", pt: "Creator" },
|
|
nav_manager_werte: { de: "Werte", "de-CH": "Wärt", en: "Values", fr: "Valeurs", pt: "Valores" },
|
|
nav_manager_bewerben: { de: "Bewerben", "de-CH": "Bewärbe", en: "Apply", fr: "Postuler", pt: "Candidatar-se" },
|
|
nav_medien: { de: "Medien", "de-CH": "Medie", en: "Media", fr: "Médias", pt: "Mídia" },
|
|
nav_medien_dogfather: { de: "DogFather", "de-CH": "DogFather", en: "DogFather", fr: "DogFather", pt: "DogFather" },
|
|
nav_medien_hasidog: { de: "HasiDog", "de-CH": "HasiDog", en: "HasiDog", fr: "HasiDog", pt: "HasiDog" },
|
|
nav_medien_casper: { de: "Casper", "de-CH": "Casper", en: "Casper", fr: "Casper", pt: "Casper" },
|
|
nav_links: { de: "Links", "de-CH": "Links", en: "Links", fr: "Liens", pt: "Links" },
|
|
nav_cta_bewerben: { de: "Bewerben 🩵", "de-CH": "Bewärbe 🩵", en: "Apply 🩵", fr: "Postuler 🩵", pt: "Candidatar-se 🩵" },
|
|
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" },
|
|
/* Sichtbarer Text NEBEN den drei Strichen (21.08.2026, Nutzer-Report per
|
|
Bild: "da soll auch menü stehen und die 3 striche"). Vorher stand nur
|
|
das Symbol ☰ da -- für jemanden, der ein Hamburger-Menü-Symbol noch nie
|
|
gesehen hat, ist nicht selbsterklärend, dass sich dahinter die
|
|
Navigation verbirgt. */
|
|
nav_toggle_label: { de: "Menü", "de-CH": "Menü", en: "Menu", fr: "Menu", pt: "Menu" },
|
|
sprache_aria: { de: "Sprache wählen", "de-CH": "Sprach uswähle", en: "Choose language", fr: "Choisir la langue", pt: "Escolher idioma" },
|
|
brand_tagline: {
|
|
de: "Streamer · Community · HasiDog · Manager",
|
|
"de-CH": "Streamer · Community · HasiDog · Manager",
|
|
en: "Streamer · Community · HasiDog · Manager",
|
|
fr: "Streamer · Communauté · HasiDog · Manager",
|
|
pt: "Streamer · Comunidade · HasiDog · Manager",
|
|
},
|
|
footer_tagline: {
|
|
de: "Streamer. Community. HasiDog. Manager. Gemeinsam stark.",
|
|
"de-CH": "Streamer. Community. HasiDog. Manager. Zäme stark.",
|
|
en: "Streamer. Community. HasiDog. Manager. Stronger together.",
|
|
fr: "Streamer. Communauté. HasiDog. Manager. Plus forts ensemble.",
|
|
pt: "Streamer. Comunidade. HasiDog. Manager. Mais fortes juntos.",
|
|
},
|
|
/* 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" },
|
|
footer_kontakt: { de: "Kontakt", "de-CH": "Kontakt", en: "Contact", fr: "Contact", pt: "Contato" },
|
|
footer_copyright: {
|
|
de: "© {jahr} DogFather Universe. Team Dogi ist Familie 🩵",
|
|
"de-CH": "© {jahr} DogFather Universe. Team Dogi isch Familie 🩵",
|
|
en: "© {jahr} DogFather Universe. Team Dogi is family 🩵",
|
|
fr: "© {jahr} DogFather Universe. Team Dogi, c'est la famille 🩵",
|
|
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: {
|
|
de: "Läuft weiter, solange du auf dieser Seite bleibst. 🩵",
|
|
"de-CH": "Laift wiiter, solang du uf dere Site bliibsch. 🩵",
|
|
en: "Keeps playing as long as you stay on this page. 🩵",
|
|
fr: "Continue de jouer tant que tu restes sur cette page. 🩵",
|
|
pt: "Continua tocando enquanto você ficar nesta página. 🩵",
|
|
},
|
|
music_toggle_title: { de: "Musik von Hasidog & Van-Van", "de-CH": "Musig vo Hasidog & Van-Van", en: "Music from Hasidog & Van-Van", fr: "Musique de Hasidog et Van-Van", pt: "Música de Hasidog e Van-Van" },
|
|
music_spotify_suffix: { de: "auf Spotify anhören", "de-CH": "uf Spotify alose", en: "listen on Spotify", fr: "écouter sur Spotify", pt: "ouvir no Spotify" },
|
|
};
|
|
|
|
function t(schluessel) {
|
|
const eintrag = I18N_CORE[schluessel];
|
|
if (!eintrag) return schluessel;
|
|
const sprache = getSprache();
|
|
return eintrag[sprache] || eintrag.de || schluessel;
|
|
}
|
|
|
|
/* Elemente mit data-i18n="schluessel" übersetzen — Wörterbuch kommt aus
|
|
window.I18N_PAGE (seiteneigen, z.B. assets/js/i18n-index.js). Fehlt
|
|
der Schlüssel oder die Sprache, bleibt der bestehende (deutsche) Text
|
|
einfach stehen, statt kaputtzugehen. */
|
|
function applyTranslations() {
|
|
const sprache = getSprache();
|
|
document.documentElement.setAttribute("lang", sprache);
|
|
|
|
const seitenWoerterbuch = window.I18N_PAGE || {};
|
|
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
|
const eintrag = seitenWoerterbuch[el.getAttribute("data-i18n")];
|
|
if (!eintrag) return;
|
|
const text = eintrag[sprache] || eintrag.de;
|
|
if (text) el.innerHTML = text;
|
|
});
|
|
|
|
// data-i18n-attr="alt:schluessel;title:andererSchluessel" — für Attribute
|
|
// statt Textinhalt (z.B. Bild-Alt-Texte).
|
|
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
|
|
el.getAttribute("data-i18n-attr")
|
|
.split(";")
|
|
.map((teil) => teil.trim())
|
|
.filter(Boolean)
|
|
.forEach((teil) => {
|
|
const [attr, schluessel] = teil.split(":");
|
|
const eintrag = seitenWoerterbuch[schluessel];
|
|
if (!eintrag) return;
|
|
const text = eintrag[sprache] || eintrag.de;
|
|
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) ---------- */
|
|
const NAV = [
|
|
{ key: "nav_start", href: "index.html" },
|
|
{
|
|
key: "nav_dogfather",
|
|
children: [
|
|
{ key: "nav_dogfather_person", href: "dogfather.html" },
|
|
{ key: "nav_dogfather_casper", href: "casper.html" },
|
|
{ key: "nav_dogfather_vanvan", href: "vanvan.html" },
|
|
{ key: "nav_dogfather_ttmuddi", href: "tt-muddi.html" },
|
|
{ key: "nav_dogfather_zeitreise", href: "zeitreise.html" },
|
|
],
|
|
},
|
|
{
|
|
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" },
|
|
{ key: "nav_hasidog_stimmen", href: "stimmen.html" },
|
|
// Streamplan zurück unter HasiDog — Wunsch Dogi.
|
|
{ key: "nav_hasidog_streamplan", href: "streamplan.html" },
|
|
],
|
|
},
|
|
{
|
|
key: "nav_manager",
|
|
children: [
|
|
{ key: "nav_manager_dogfather", href: "manager.html" },
|
|
{ key: "nav_manager_scouts", href: "team-scouts.html" },
|
|
{ key: "nav_manager_creator", href: "creator.html" },
|
|
{ key: "nav_manager_werte", href: "werte.html" },
|
|
{ key: "nav_manager_bewerben", href: "bewerben.html" },
|
|
],
|
|
},
|
|
{
|
|
// Medien ist bewusst nach den 3 Welten aufgeteilt statt nach
|
|
// Foto/Video/Aktionen getrennt zu sein — Wunsch von Dogi: pro Welt
|
|
// (DogFather/HasiDog/Casper) gibt es EINE Seite mit allem
|
|
// kategorisiert (Fotos, Videos, Events), siehe medien-*.html.
|
|
key: "nav_medien",
|
|
children: [
|
|
{ key: "nav_medien_dogfather", href: "medien-dogfather.html" },
|
|
{ key: "nav_medien_hasidog", href: "medien-hasidog.html" },
|
|
{ key: "nav_medien_casper", href: "medien-casper.html" },
|
|
],
|
|
},
|
|
{ key: "nav_links", href: "links.html" },
|
|
];
|
|
|
|
/* Aktuelle Datei — wird beim nahtlosen Seitenwechsel mit aktualisiert. */
|
|
let currentFile = (location.pathname.split("/").pop() || "index.html");
|
|
|
|
function isActive(href) {
|
|
return href === currentFile;
|
|
}
|
|
|
|
/* Ordnet jedem Nav-Punkt die Farbklasse SEINER Welt zu (siehe
|
|
.nav-item-* in main.css, Farben aus theme-dogfather/-hasidog/
|
|
-spicymedia.css) — für den augenschonenden Polarlicht-Schimmer pro
|
|
Kategorie. "Links" bekommt bewusst keine Farbe (keine eigene Welt,
|
|
sitzt direkt neben dem schon bunten Bewerben-Knopf). */
|
|
const NAV_FARBKLASSE = {
|
|
nav_start: "nav-item-start",
|
|
nav_dogfather: "nav-item-dogfather",
|
|
nav_hasidog: "nav-item-hasidog",
|
|
nav_manager: "nav-item-manager",
|
|
nav_medien: "nav-item-medien",
|
|
nav_links: "nav-item-links",
|
|
};
|
|
|
|
function renderNavItem(item) {
|
|
const farbklasse = NAV_FARBKLASSE[item.key] ? ` ${NAV_FARBKLASSE[item.key]}` : "";
|
|
if (!item.children) {
|
|
return `<a class="nav-flat${farbklasse}${isActive(item.href) ? " active" : ""}" href="${item.href}">${t(item.key)}</a>`;
|
|
}
|
|
const activeChild = item.children.some((c) => isActive(c.href));
|
|
// Farbklasse sitzt am äußeren .nav-group, nicht am Button — so
|
|
// erben Button-Aurora UND Dropdown (beides Kinder von .nav-group)
|
|
// dieselben --brand-1/--brand-2-Variablen für einen einheitlichen
|
|
// Farbklang zwischen Kategorie-Knopf und aufgeklapptem Menü.
|
|
return `
|
|
<div class="nav-group${farbklasse}${activeChild ? " open-hint" : ""}">
|
|
<button type="button" aria-expanded="false">${t(item.key)} <span aria-hidden="true">▾</span></button>
|
|
<div class="nav-dropdown">
|
|
${item.children
|
|
.map(
|
|
(c) =>
|
|
`<a href="${c.href}"${isActive(c.href) ? ' class="active"' : ""}>${t(c.key)}</a>`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* Sprachumschalter — eigener nav-group-artiger Dropdown, zeigt die
|
|
aktuell gewählte Sprache (Flagge + Kürzel) und listet beim Klick die
|
|
übrigen 4 Sprachen auf. */
|
|
/* Aus den 2-3 Landesfarben einer Sprache ein Aurora-Wolkenbild bauen
|
|
(mehrere versetzte, weiche Farbflecken — dieselbe Machart wie beim
|
|
Bewerben-Knopf, nur mit den Farben DIESER Sprache) plus eine
|
|
Stopp-Liste für den conic-gradient der Kugel. */
|
|
function sprachAurora(farben) {
|
|
const positionen = ["22% 30%", "72% 28%", "48% 78%"];
|
|
return farben
|
|
.map((f, i) => `radial-gradient(circle at ${positionen[i % positionen.length]}, ${f}, transparent 60%)`)
|
|
.join(", ");
|
|
}
|
|
function sprachKugelStopps(farben) {
|
|
return [...farben, farben[0]].join(", ");
|
|
}
|
|
|
|
function renderSprachSchalter() {
|
|
const aktuell = getSprache();
|
|
const aktiv = SPRACHEN.find((s) => s.code === aktuell) || SPRACHEN[0];
|
|
const styleFuer = (s) =>
|
|
`--sprach-glut:${s.glut};--sprach-aurora:${sprachAurora(s.farbstopps)};--sprach-stopps:${sprachKugelStopps(s.farbstopps)};`;
|
|
return `
|
|
<div class="nav-group sprach-schalter" style="${styleFuer(aktiv)}">
|
|
<button type="button" aria-expanded="false" aria-label="${t("sprache_aria")}">
|
|
<span class="sprach-kugel" aria-hidden="true"></span>
|
|
<span class="sprach-kurz">${aktiv.kurz}</span> <span aria-hidden="true">▾</span>
|
|
</button>
|
|
<div class="nav-dropdown">
|
|
${SPRACHEN.map(
|
|
(s) =>
|
|
`<a href="#" data-sprache="${s.code}" style="${styleFuer(s)}"${s.code === aktuell ? ' class="active"' : ""}><span class="sprach-kugel" aria-hidden="true"></span>${s.name}</a>`
|
|
).join("")}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ===================================================================
|
|
Vorschau-Modus für Dogi + VanVan (Zugangsschranke, siehe gate-worker.js)
|
|
|
|
Solange die Seite noch nicht öffentlich ist, kommen nur Dogi und VanVan
|
|
per Zugangscode rein (gate-worker.js setzt dabei ein lesbares Cookie
|
|
"dogi_role=dogi"/"vanvan" — NICHT das eigentliche Sicherheits-Cookie,
|
|
das bleibt HttpOnly). Damit die beiden die Seite auch "als Abonnent"
|
|
bzw. "als Nicht-Abonnent" ansehen können, OHNE ein echtes Abo
|
|
abschließen zu müssen, gibt es einen kleinen Umschalter unten links,
|
|
der NUR erscheint, wenn das Rollen-Cookie gesetzt ist. Die Wahl landet
|
|
in localStorage und wird von renderAboButton() sowie von supporter.js/
|
|
supporter.html (Mock-Daten statt echtem API-Aufruf) ausgelesen.
|
|
=================================================================== */
|
|
const VORSCHAU_SCHLUESSEL = "dogiPreviewMode"; // "abonnent" | "nicht-abonnent" | leer = echter Stand
|
|
|
|
function getCookieWert(name) {
|
|
const treffer = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
return treffer ? decodeURIComponent(treffer[1]) : null;
|
|
}
|
|
|
|
function getVorschauRolle() {
|
|
return getCookieWert("dogi_role"); // "dogi" | "vanvan" | null
|
|
}
|
|
|
|
/* Realistischer Anzeigename fürs Vorschau-Konto — Nutzer-Feedback
|
|
03.08.2026: "ich will in der Vorschau alles sehen wie es fertig sein
|
|
wird, nix da stehen haben von Vorschau, das bringt mir nix." Statt dem
|
|
Wort "Vorschau" irgendwo im Text zeigt die Vorschau jetzt einfach den
|
|
Namen der Person, die gerade eingeloggt ist (Dogi oder VanVan) — sieht
|
|
dadurch exakt so aus wie ein echtes, fertiges Konto. */
|
|
function vorschauAnzeigename() {
|
|
const rolle = getVorschauRolle();
|
|
return rolle === "vanvan" ? "VanVan" : "Dogi";
|
|
}
|
|
window.dogiVorschauName = vorschauAnzeigename; // öffentlich, für supporter.html
|
|
|
|
function getVorschauModus() {
|
|
if (!getVorschauRolle()) return null; // Umschalter nur für Dogi/VanVan gültig
|
|
const wert = localStorage.getItem(VORSCHAU_SCHLUESSEL);
|
|
return wert === "abonnent" || wert === "nicht-abonnent" ? wert : null;
|
|
}
|
|
|
|
function setVorschauModus(modus) {
|
|
if (modus) localStorage.setItem(VORSCHAU_SCHLUESSEL, modus);
|
|
else localStorage.removeItem(VORSCHAU_SCHLUESSEL);
|
|
renderHeader();
|
|
renderVorschauSchalter();
|
|
document.dispatchEvent(new CustomEvent("dogi-vorschau-geaendert", { detail: { modus } }));
|
|
}
|
|
window.dogiVorschauModus = getVorschauModus; // öffentlich, für supporter.html/abonnieren.html
|
|
|
|
/* Aufklappbarer Kreisknopf unten links, spiegelbildlich zum Musik-Knopf
|
|
unten rechts (gleiche Bauart, siehe .vorschau-toggle/.vorschau-panel in
|
|
main.css) — Nutzer-Feedback 03.08.2026: statt einer dauerhaft offenen
|
|
Text-Knopfleiste "so ein Button wie bei VanVan", der sich erst bei
|
|
Klick öffnet. Nur EINMAL erzeugt (wie beim Musik-Widget), damit ein
|
|
offenes Panel beim nahtlosen Seitenwechsel nicht jedes Mal neu gebaut
|
|
wird — nur der Inhalt (aktive Auswahl) wird bei jedem Aufruf aktuell
|
|
gehalten. */
|
|
function renderVorschauSchalter() {
|
|
if (!getVorschauRolle()) return; // öffentliche Besucher sehen nie etwas
|
|
|
|
let widget = document.getElementById("vorschau-widget");
|
|
if (!widget) {
|
|
widget = document.createElement("div");
|
|
widget.className = "vorschau-widget";
|
|
widget.id = "vorschau-widget";
|
|
widget.innerHTML = `
|
|
<div class="vorschau-panel" id="vorschau-panel" hidden>
|
|
<div class="vorschau-panel-head">
|
|
<strong>🎭 Vorschau</strong>
|
|
<button class="vorschau-close" id="vorschau-close" type="button" aria-label="Vorschau-Feld schließen">✕</button>
|
|
</div>
|
|
<div class="vorschau-optionen" id="vorschau-optionen"></div>
|
|
</div>
|
|
<button class="vorschau-toggle" id="vorschau-toggle" type="button"
|
|
aria-expanded="false" aria-controls="vorschau-panel"
|
|
title="Vorschau: als Abonnent/Nicht-Abonnent ansehen"><span>🎭</span></button>`;
|
|
document.body.appendChild(widget);
|
|
|
|
const panel = document.getElementById("vorschau-panel");
|
|
const toggle = document.getElementById("vorschau-toggle");
|
|
const oeffnen = (auf) => {
|
|
panel.hidden = !auf;
|
|
toggle.setAttribute("aria-expanded", String(auf));
|
|
widget.classList.toggle("open", auf);
|
|
};
|
|
toggle.addEventListener("click", () => oeffnen(panel.hidden));
|
|
document.getElementById("vorschau-close").addEventListener("click", () => oeffnen(false));
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && !panel.hidden) oeffnen(false);
|
|
});
|
|
document.getElementById("vorschau-optionen").addEventListener("click", (e) => {
|
|
const btn = e.target.closest("button[data-vorschau]");
|
|
if (!btn) return;
|
|
setVorschauModus(btn.dataset.vorschau === "echt" ? null : btn.dataset.vorschau);
|
|
});
|
|
}
|
|
|
|
const aktuell = getVorschauModus();
|
|
const knopf = (wert, label) => {
|
|
const aktiv = wert === "echt" ? !aktuell : aktuell === wert;
|
|
return `<button type="button" data-vorschau="${wert}" class="${aktiv ? "aktiv" : ""}">${aktiv ? "✓ " : ""}${label}</button>`;
|
|
};
|
|
document.getElementById("vorschau-optionen").innerHTML =
|
|
knopf("echt", "Echter Login-Stand") + knopf("abonnent", "Als Abonnent") + knopf("nicht-abonnent", "Als Nicht-Abonnent");
|
|
}
|
|
|
|
/* Abo-Button in der Hauptnavigation (Dogfather_VanVan_Supporter_Abo.odt,
|
|
Abschnitt 3) — Beschriftung/Ziel hängt vom Supporter-Login-Stand ab.
|
|
Bewusst KEIN API-Aufruf bei jedem Seitenaufruf (würde jede einzelne
|
|
Seite der Website an den Worker koppeln) — stattdessen wird der zuletzt
|
|
bekannte Stand aus localStorage gelesen, den supporter.html/
|
|
abonnieren.html bei jedem eigenen Laden aktuell hält (siehe dort).
|
|
Vorschau-Modus (Dogi/VanVan, siehe oben) überschreibt das für den
|
|
Nav-Knopf, unabhängig vom echten Login-Stand.
|
|
|
|
Nutzer-Feedback 03.08.2026: Ist man eingeloggt UND hat ein Abo, soll im
|
|
Knopf nicht der generische Text "Supporter-Bereich" stehen, sondern der
|
|
eigene Benutzername (aus /supporter/me → profile.displayName, wird von
|
|
abonnieren.html/supporter.html per S.cacheDisplayName() aktuell
|
|
gehalten). displayName ist freier Nutzertext -> escapeHtml() ist
|
|
Pflicht (sonst XSS über den eigenen Anzeigenamen möglich), und wird auf
|
|
eine im Knopf sinnvolle Länge gekürzt. */
|
|
function escapeHtml(text) {
|
|
return String(text).replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
|
}
|
|
function kuerzen(text, maxLaenge) {
|
|
return text.length > maxLaenge ? text.slice(0, maxLaenge - 1).trimEnd() + "…" : text;
|
|
}
|
|
function bereichLabel(name) {
|
|
const sauber = (name || "").trim();
|
|
if (!sauber) return t("nav_cta_abo_bereich");
|
|
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") {
|
|
// Ist Dogi/VanVan zufällig wirklich mit einem echten Konto
|
|
// eingeloggt (echter Name im Speicher), soll auch in der Vorschau
|
|
// DER echte Name stehen. Ohne echtes Konto fällt es auf den Namen
|
|
// der eingeloggten Rolle zurück (vorschauAnzeigename() — "Dogi" oder
|
|
// "VanVan"), NICHT auf einen erkennbaren "Vorschau"-Platzhalter,
|
|
// damit die Vorschau exakt so aussieht wie das fertige Ergebnis
|
|
// (Nutzer-Feedback 03.08.2026: "nix da stehen haben von Vorschau,
|
|
// das bringt mir nix").
|
|
const echterName = localStorage.getItem("supporterDisplayName");
|
|
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>${soonBadge()}</a>`;
|
|
}
|
|
|
|
const token = localStorage.getItem("supporterToken");
|
|
if (!token) {
|
|
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span>${soonBadge()}</a>`;
|
|
}
|
|
/* Nutzer-Wunsch 21.08.2026: "meine seite ändert sich nicht und es gibt
|
|
keinen button für registrierte sich normal anzumelden und abzumelden.
|
|
wo sie die speziellen sachen sehen als supporter und dass sie auch
|
|
schon den abo sehen aber gesperrt ist." Vorher landeten Leute mit
|
|
Konto, aber ohne bezahltes Abo (status "none"), im Nav-Knopf wieder
|
|
nur bei abonnieren.html -- man merkte dem Knopf also nie an, dass man
|
|
überhaupt eingeloggt ist. supporter.html zeigt für genau diesen Fall
|
|
schon lange den Bereich "Mein Abo" -> #supp-keine-abo ("Du hast noch
|
|
kein aktives Abo" + Knopf) UND den Ausloggen-Knopf (siehe dort,
|
|
#btn-logout) -- das musste also nur noch verlinkt werden. Ab jetzt:
|
|
JEDER mit gültigem Token (egal ob "none", "active", "pending", ...)
|
|
geht auf supporter.html und sieht dort automatisch den passenden
|
|
Zustand samt Logout. */
|
|
const name = localStorage.getItem("supporterDisplayName");
|
|
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(name)}</span></a>`;
|
|
}
|
|
|
|
function renderHeader() {
|
|
const el = document.getElementById("site-header");
|
|
if (!el) return;
|
|
|
|
/* Sprungziel vorbereiten.
|
|
|
|
Das tabindex="-1" ist der entscheidende Teil und wird fast überall
|
|
vergessen: Ein Sprung auf ein Element, das von sich aus keinen Fokus
|
|
annehmen kann, verschiebt in Chrome und Safari nur die Bildlaufleiste
|
|
-- der Tastaturfokus bleibt oben in der Navigation stehen. Der nächste
|
|
Tab landet dann wieder im Menü, und der Sprung war wirkungslos.
|
|
Mit -1 nimmt <main> den Fokus programmatisch an, taucht aber selbst
|
|
NICHT in der Tab-Reihenfolge auf.
|
|
|
|
Beides nur setzen, wenn nichts dasteht: Falls eine Seite schon eine
|
|
eigene id oder ein eigenes tabindex hat, wird das nicht überfahren. */
|
|
const inhalt = document.querySelector("main");
|
|
if (inhalt) {
|
|
if (!inhalt.id) inhalt.id = "inhalt";
|
|
if (!inhalt.hasAttribute("tabindex")) inhalt.setAttribute("tabindex", "-1");
|
|
}
|
|
|
|
el.innerHTML = `
|
|
${inhalt ? `<a class="site-skip" href="#${inhalt.id}" data-i18n="nav_zum_inhalt">${t("nav_zum_inhalt")}</a>` : ""}
|
|
<header class="site-header">
|
|
<div class="nav-bar">
|
|
<a class="brand" href="index.html">
|
|
<img class="brand-logo" src="assets/img/logo-dogfather.png" alt="DogFather Logo" />
|
|
<span>DOGFATHER UNIVERSE
|
|
<small>${t("brand_tagline")}</small>
|
|
</span>
|
|
</a>
|
|
<button class="nav-toggle" type="button" aria-label="${t("nav_toggle_aria")}" aria-expanded="false"><span aria-hidden="true">☰</span><span class="nav-toggle-label">${t("nav_toggle_label")}</span></button>
|
|
<nav class="nav-links">
|
|
${NAV.map(renderNavItem).join("")}
|
|
${renderSprachSchalter()}
|
|
${renderAboButton()}
|
|
<a class="nav-cta" href="bewerben.html"><span>${t("nav_cta_bewerben")}</span></a>
|
|
</nav>
|
|
</div>
|
|
</header>`;
|
|
|
|
const toggle = el.querySelector(".nav-toggle");
|
|
const links = el.querySelector(".nav-links");
|
|
toggle.addEventListener("click", () => {
|
|
const open = links.classList.toggle("open");
|
|
toggle.setAttribute("aria-expanded", String(open));
|
|
});
|
|
|
|
el.querySelectorAll(".nav-group > button").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const group = btn.parentElement;
|
|
const wasOpen = group.classList.contains("open");
|
|
el.querySelectorAll(".nav-group.open").forEach((g) => g.classList.remove("open"));
|
|
if (!wasOpen) group.classList.add("open");
|
|
btn.setAttribute("aria-expanded", String(!wasOpen));
|
|
});
|
|
});
|
|
|
|
/* 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();
|
|
setSprache(link.dataset.sprache);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* Klick außerhalb schließt offene Menüs. Bewusst NUR EINMAL angemeldet —
|
|
renderHeader() läuft bei jedem Seitenwechsel erneut, und ein Zuhörer
|
|
pro Wechsel würde sich sonst endlos aufsummieren. */
|
|
document.addEventListener("click", (e) => {
|
|
const kopf = document.getElementById("site-header");
|
|
if (kopf && !kopf.contains(e.target)) {
|
|
kopf.querySelectorAll(".nav-group.open").forEach((g) => g.classList.remove("open"));
|
|
}
|
|
});
|
|
|
|
function renderFooter() {
|
|
const el = document.getElementById("site-footer");
|
|
if (!el) return;
|
|
const year = new Date().getFullYear();
|
|
el.innerHTML = `
|
|
<footer class="site-footer">
|
|
<div class="container footer-grid">
|
|
<div>
|
|
<div class="brand"><span class="heart">🩵</span> DOGFATHER UNIVERSE</div>
|
|
<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>${t("nav_dogfather")}</h4>
|
|
<ul>
|
|
<li><a href="dogfather.html">${t("nav_dogfather_person")}</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>
|
|
<li><a href="zeitreise.html">${t("nav_dogfather_zeitreise")}</a></li>
|
|
</ul>
|
|
</div>
|
|
<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>
|
|
<li><a href="stimmen.html">${t("nav_hasidog_stimmen")}</a></li>
|
|
<li><a href="streamplan.html">${t("nav_hasidog_streamplan")}</a></li>
|
|
<li><a href="manager.html">${t("nav_manager_dogfather")}</a></li>
|
|
<li><a href="team-scouts.html">${t("footer_scouts")}</a></li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h4>${t("footer_h4_mehr")}</h4>
|
|
<ul>
|
|
<li><a href="links.html">${t("footer_alle_links")}</a></li>
|
|
<li><a href="bewerben.html">${t("nav_manager_bewerben")}</a></li>
|
|
<li><a href="kontakt.html">${t("footer_kontakt")}</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<div class="container footer-bottom">
|
|
<span>${t("footer_copyright").replace("{jahr}", year)}</span>
|
|
<span><a href="kontakt.html">${t("footer_impressum")}</a> · <a href="agb.html">AGB</a></span>
|
|
</div>
|
|
</footer>`;
|
|
}
|
|
|
|
function renderFog() {
|
|
if (document.querySelector(".fog-layer")) return;
|
|
const layer = document.createElement("div");
|
|
layer.className = "fog-layer";
|
|
layer.setAttribute("aria-hidden", "true");
|
|
const blobs = 6;
|
|
let html = "";
|
|
for (let i = 0; i < blobs; i++) {
|
|
const size = 220 + Math.round(Math.random() * 260);
|
|
const top = Math.round(Math.random() * 100);
|
|
const left = Math.round(Math.random() * 100);
|
|
const delay = (Math.random() * 10).toFixed(1);
|
|
html += `<span style="width:${size}px;height:${size}px;top:${top}%;left:${left}%;animation-delay:${delay}s;"></span>`;
|
|
}
|
|
layer.innerHTML = html;
|
|
document.body.prepend(layer);
|
|
}
|
|
|
|
/* ===================================================================
|
|
Musik-Knopf (unten rechts, auf jeder Seite)
|
|
|
|
Kleiner schwebender Knopf, der ein Feld mit den Spotify-Playern von
|
|
Hasidog und Van-Van aufklappt. Genauso gelöst wie im Analyse-Tool.
|
|
|
|
Zwei bewusste Entscheidungen:
|
|
1. Das Feld hängt direkt am <body>, nicht in einem Abschnitt — so
|
|
kann kein Neuaufbau der Seite den laufenden Player unterbrechen.
|
|
2. Die Player werden ERST beim ersten Öffnen geladen (src wird dann
|
|
gesetzt). Sonst würde jede Seite zwei fremde Einbettungen laden,
|
|
auch wenn niemand Musik hören will.
|
|
=================================================================== */
|
|
const SPOTIFY_ARTISTS = [
|
|
{ name: "Hasidog", id: "2INiE1VVJGUQdEURsltuJU" },
|
|
{ name: "Van-Van", id: "4J9PcKZBkh6V8dVY3CGliw" },
|
|
];
|
|
|
|
function renderMusicWidget() {
|
|
if (document.getElementById("music-widget")) return;
|
|
|
|
const widget = document.createElement("div");
|
|
widget.className = "music-widget";
|
|
widget.id = "music-widget";
|
|
widget.innerHTML = `
|
|
<div class="music-panel" id="music-panel" hidden>
|
|
<div class="music-panel-head">
|
|
<strong id="music-title-text">${t("music_title")}</strong>
|
|
<button class="music-close" id="music-close" type="button" aria-label="${t("music_close_aria")}">✕</button>
|
|
</div>
|
|
<p class="small muted" id="music-intro-text" style="margin:.2rem 0 .6rem;">${t("music_intro")}</p>
|
|
${SPOTIFY_ARTISTS.map(
|
|
(a) => `
|
|
<p class="music-artist-label">${a.name}</p>
|
|
<iframe data-src="https://open.spotify.com/embed/artist/${a.id}?utm_source=generator&theme=0"
|
|
height="152" loading="lazy" data-artist="${a.name}" title="${a.name} ${t("music_spotify_suffix")}"
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
allowfullscreen></iframe>`
|
|
).join("")}
|
|
</div>
|
|
<button class="music-toggle" id="music-toggle" type="button"
|
|
aria-expanded="false" aria-controls="music-panel"
|
|
title="${t("music_toggle_title")}"><span>🎵</span></button>`;
|
|
document.body.appendChild(widget);
|
|
|
|
const panel = document.getElementById("music-panel");
|
|
const toggle = document.getElementById("music-toggle");
|
|
let geladen = false;
|
|
|
|
function oeffnen(auf) {
|
|
panel.hidden = !auf;
|
|
toggle.setAttribute("aria-expanded", String(auf));
|
|
widget.classList.toggle("open", auf);
|
|
if (auf && !geladen) {
|
|
// Player erst jetzt wirklich laden
|
|
panel.querySelectorAll("iframe[data-src]").forEach((f) => {
|
|
f.src = f.dataset.src;
|
|
f.removeAttribute("data-src");
|
|
});
|
|
geladen = true;
|
|
}
|
|
}
|
|
|
|
toggle.addEventListener("click", () => oeffnen(panel.hidden));
|
|
document.getElementById("music-close").addEventListener("click", () => oeffnen(false));
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && !panel.hidden) oeffnen(false);
|
|
});
|
|
}
|
|
|
|
/* Das Musik-Widget wird nur EINMAL erzeugt (damit der laufende Player
|
|
beim Seitenwechsel nicht abreißt) — bei Sprachwechsel deshalb nicht
|
|
neu rendern, sondern nur die Texte/Attribute darin austauschen. */
|
|
function applyMusicWidgetSprache() {
|
|
const titel = document.getElementById("music-title-text");
|
|
if (titel) titel.textContent = t("music_title");
|
|
const schliessen = document.getElementById("music-close");
|
|
if (schliessen) schliessen.setAttribute("aria-label", t("music_close_aria"));
|
|
const intro = document.getElementById("music-intro-text");
|
|
if (intro) intro.textContent = t("music_intro");
|
|
const toggle = document.getElementById("music-toggle");
|
|
if (toggle) toggle.setAttribute("title", t("music_toggle_title"));
|
|
document.querySelectorAll("#music-widget iframe[data-artist]").forEach((f) => {
|
|
f.setAttribute("title", `${f.dataset.artist} ${t("music_spotify_suffix")}`);
|
|
});
|
|
}
|
|
|
|
/* ===================================================================
|
|
Musik-Knopf: Chamäleon-Farben
|
|
|
|
Der Knopf soll nicht nur zwischen 3 festen Weltfarben wechseln,
|
|
sondern sich an das tatsächliche Bild JEDER einzelnen Seite anpassen
|
|
— Durchschnittsfarbe plus die kräftigste (sattste) Farbe darin, als
|
|
kleiner Verlauf. Bildquelle in Prioritätsreihenfolge:
|
|
1. Das scharfe Banner-Bild im Kopfbereich (.hero-banner img)
|
|
2. Das Artwork der Startseite (.hero-artwork, CSS-Hintergrund)
|
|
3. Das Ambiente-Hintergrundbild der Seite (--page-bg am <body>)
|
|
Gibt es nichts davon (reine Text-Hero-Seiten wie Links oder Videos),
|
|
fällt der Knopf sauber auf die normale Weltfarbe zurück (siehe CSS:
|
|
var(--music-c1, var(--accent))).
|
|
|
|
Bilder sind alle selbst gehostet (gleiche Domain) — Canvas-Auslesen
|
|
funktioniert dadurch ohne CORS-Probleme.
|
|
=================================================================== */
|
|
function bildquelleErmitteln() {
|
|
const banner = document.querySelector(".hero-banner img");
|
|
if (banner && banner.getAttribute("src")) return banner;
|
|
|
|
const artwork = document.querySelector(".hero-artwork");
|
|
if (artwork) {
|
|
const hintergrund = getComputedStyle(artwork, "::before").backgroundImage;
|
|
const ersteEbene = hintergrund && hintergrund.split(",")[0];
|
|
const treffer = ersteEbene && ersteEbene.match(/url\(["']?(.*?)["']?\)/);
|
|
if (treffer && treffer[1]) return treffer[1];
|
|
}
|
|
|
|
const seitenBg = getComputedStyle(document.body).getPropertyValue("--page-bg");
|
|
const treffer2 = seitenBg && seitenBg.match(/url\(["']?(.*?)["']?\)/);
|
|
if (treffer2 && treffer2[1]) return treffer2[1];
|
|
|
|
return null;
|
|
}
|
|
|
|
function rgbNachHsl(r, g, b) {
|
|
r /= 255; g /= 255; b /= 255;
|
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
let h = 0, s = 0;
|
|
const l = (max + min) / 2;
|
|
if (max !== min) {
|
|
const d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
switch (max) {
|
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
|
case g: h = (b - r) / d + 2; break;
|
|
default: h = (r - g) / d + 4;
|
|
}
|
|
h *= 60;
|
|
}
|
|
return [h, s * 100, l * 100];
|
|
}
|
|
|
|
const begrenzen = (wert, min, max) => Math.min(max, Math.max(min, wert));
|
|
|
|
let musikFarbLauf = 0;
|
|
|
|
function musikKnopfFarbenAnpassen() {
|
|
const knopf = document.getElementById("music-toggle");
|
|
if (!knopf) return;
|
|
const lauf = ++musikFarbLauf;
|
|
|
|
const zuruecksetzen = () => {
|
|
knopf.style.removeProperty("--music-c1");
|
|
knopf.style.removeProperty("--music-c2");
|
|
};
|
|
|
|
const quelle = bildquelleErmitteln();
|
|
if (!quelle) {
|
|
zuruecksetzen();
|
|
return;
|
|
}
|
|
|
|
const verarbeiten = (bild) => {
|
|
if (lauf !== musikFarbLauf) return; // zwischenzeitlich schon weitergeklickt
|
|
try {
|
|
const groesse = 28;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = groesse;
|
|
canvas.height = groesse;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(bild, 0, 0, groesse, groesse);
|
|
const daten = ctx.getImageData(0, 0, groesse, groesse).data;
|
|
|
|
let rSumme = 0, gSumme = 0, bSumme = 0, n = 0;
|
|
let besteSaettigung = -1, satR = 0, satG = 0, satB = 0;
|
|
for (let i = 0; i < daten.length; i += 4) {
|
|
const r = daten[i], g = daten[i + 1], b = daten[i + 2];
|
|
rSumme += r; gSumme += g; bSumme += b; n++;
|
|
const hsl = rgbNachHsl(r, g, b);
|
|
if (hsl[2] > 15 && hsl[2] < 88 && hsl[1] > besteSaettigung) {
|
|
besteSaettigung = hsl[1]; satR = r; satG = g; satB = b;
|
|
}
|
|
}
|
|
const r = Math.round(rSumme / n), g = Math.round(gSumme / n), b = Math.round(bSumme / n);
|
|
|
|
const [h1, s1, l1] = rgbNachHsl(r, g, b);
|
|
const farbe1 = `hsl(${Math.round(h1)}deg ${Math.round(begrenzen(s1, 42, 92))}% ${Math.round(begrenzen(l1, 40, 62))}%)`;
|
|
|
|
let farbe2;
|
|
if (besteSaettigung > 18) {
|
|
const [h2, s2, l2] = rgbNachHsl(satR, satG, satB);
|
|
farbe2 = `hsl(${Math.round(h2)}deg ${Math.round(begrenzen(s2, 50, 95))}% ${Math.round(begrenzen(l2, 42, 66))}%)`;
|
|
} else {
|
|
farbe2 = `hsl(${Math.round((h1 + 45) % 360)}deg ${Math.round(begrenzen(s1, 45, 90))}% ${Math.round(begrenzen(l1 + 8, 42, 68))}%)`;
|
|
}
|
|
|
|
knopf.style.setProperty("--music-c1", farbe1);
|
|
knopf.style.setProperty("--music-c2", farbe2);
|
|
} catch {
|
|
zuruecksetzen(); // z.B. wenn das Bild aus irgendeinem Grund nicht lesbar ist
|
|
}
|
|
};
|
|
|
|
if (typeof quelle === "string") {
|
|
const bild = new Image();
|
|
bild.addEventListener("load", () => verarbeiten(bild));
|
|
bild.addEventListener("error", zuruecksetzen);
|
|
bild.src = quelle;
|
|
} else if (quelle.complete && quelle.naturalWidth > 0) {
|
|
verarbeiten(quelle);
|
|
} else {
|
|
quelle.addEventListener("load", () => verarbeiten(quelle), { once: true });
|
|
quelle.addEventListener("error", zuruecksetzen, { once: true });
|
|
}
|
|
}
|
|
|
|
/* ===================================================================
|
|
Nahtloser Seitenwechsel — damit die Musik nicht abreißt
|
|
|
|
Normalerweise lädt der Browser bei jedem Klick die ganze Seite neu,
|
|
wodurch der Spotify-Player jedes Mal von vorn startet. Stattdessen
|
|
holen wir die Zielseite im Hintergrund und tauschen nur den
|
|
Inhaltsbereich <main> aus. Kopfzeile, Fußzeile und der Musik-Knopf
|
|
bleiben stehen — die Musik läuft einfach weiter.
|
|
|
|
Die Liste unten kann bei Bedarf wieder Seiten aufnehmen, die bewusst
|
|
NICHT nahtlos geladen werden sollen (z.B. künftige interne Bereiche
|
|
mit eigenem Login) — aktuell gibt es keine solche Seite mehr.
|
|
|
|
Absicherung: Geht beim Holen irgendetwas schief, wird ganz normal
|
|
zur Zielseite navigiert. Schlimmstenfalls ist es also wie vorher.
|
|
=================================================================== */
|
|
const KEIN_NAHTLOSER_WECHSEL = [];
|
|
|
|
/* Seiteneigene Skripte melden sich fast alle über "DOMContentLoaded" an.
|
|
Nach dem ersten Laden ist dieses Ereignis längst vorbei — ein später
|
|
nachgeladenes Skript würde also nie starten. Deshalb: Anmeldungen, die
|
|
NACH dem Laden eintreffen, sofort ausführen. Während des normalen
|
|
Erstaufbaus (readyState === "loading") bleibt alles wie gehabt. */
|
|
const anmeldenOriginal = document.addEventListener.bind(document);
|
|
document.addEventListener = function (typ, fn, optionen) {
|
|
if (typ === "DOMContentLoaded" && document.readyState !== "loading" && typeof fn === "function") {
|
|
setTimeout(() => fn(new Event("DOMContentLoaded")), 0);
|
|
return;
|
|
}
|
|
return anmeldenOriginal(typ, fn, optionen);
|
|
};
|
|
|
|
/** Skripte der neuen Seite der Reihe nach ausführen. */
|
|
async function skripteUebernehmen(neuesDokument) {
|
|
const schonGeladen = new Set(
|
|
[...document.querySelectorAll("script[src]")].map((s) => s.getAttribute("src"))
|
|
);
|
|
|
|
for (const alt of [...neuesDokument.querySelectorAll("script")]) {
|
|
const src = alt.getAttribute("src");
|
|
|
|
if (src) {
|
|
// i18n-*.js-Dateien setzen nur window.I18N_PAGE neu (kein Zustand,
|
|
// keine Event-Listener) — die MÜSSEN bei jedem Besuch der Seite
|
|
// 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.
|
|
//
|
|
// 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.
|
|
if (schonGeladen.has(src) && !istSprachDatei) continue;
|
|
schonGeladen.add(src);
|
|
await new Promise((fertig) => {
|
|
const neu = document.createElement("script");
|
|
neu.src = src; // bei i18n-Dateien bewusst dieselbe URL — der Browser
|
|
// bedient das aus dem HTTP-Cache, das <script>-Element wird aber
|
|
// trotzdem neu eingefügt und läuft deshalb garantiert erneut.
|
|
neu.async = false;
|
|
neu.onload = neu.onerror = fertig; // auch bei Fehler weitermachen
|
|
document.body.appendChild(neu);
|
|
});
|
|
} else {
|
|
// Seiteneigenes Skript (z.B. Team-Vorschau der Startseite).
|
|
// Läuft erst, wenn die Datendateien davor fertig geladen sind.
|
|
const neu = document.createElement("script");
|
|
neu.textContent = alt.textContent;
|
|
document.body.appendChild(neu);
|
|
}
|
|
}
|
|
}
|
|
|
|
let ladeVorgang = 0;
|
|
|
|
async function seiteLaden(url, mitVerlauf) {
|
|
const lauf = ++ladeVorgang;
|
|
document.body.classList.add("seite-laedt");
|
|
|
|
let dok;
|
|
try {
|
|
const antwort = await fetch(url, { credentials: "same-origin" });
|
|
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
|
dok = new DOMParser().parseFromString(await antwort.text(), "text/html");
|
|
} catch {
|
|
location.href = url; // Rückfall: ganz normal navigieren
|
|
return;
|
|
}
|
|
if (lauf !== ladeVorgang) return; // zwischenzeitlich woanders hingeklickt
|
|
|
|
const neuerInhalt = dok.querySelector("main");
|
|
const alterInhalt = document.querySelector("main");
|
|
if (!neuerInhalt || !alterInhalt) {
|
|
location.href = url;
|
|
return;
|
|
}
|
|
|
|
alterInhalt.replaceWith(neuerInhalt);
|
|
|
|
// Seiteneigene <style>-Blöcke im <head> mitnehmen (Nutzer-Feedback
|
|
// 03.08.2026: "wenn ich den Abo-Knopf drücke, sieht die Seite zuerst
|
|
// kaputt aus, erst nach einem manuellen Neuladen stimmt es"). Ursache:
|
|
// viele Seiten (abonnieren.html, supporter.html, rabattcodes.html,
|
|
// tt-muddi.html, ...) haben ihr eigenes Feintuning als <style> direkt
|
|
// im <head> stehen. Beim nahtlosen Wechsel wurde bisher NUR <main>
|
|
// ausgetauscht — der <head> blieb unverändert, das eigene <style> der
|
|
// neuen Seite kam nie mit. Ein echter Neuladen (F5) parst das
|
|
// Dokument komplett neu und hatte das Problem deshalb nie gezeigt.
|
|
// Alle mit main.js/main.css geteilten Styles kommen ausschließlich
|
|
// über <link rel="stylesheet">, nie über ein <style> im <head> — jedes
|
|
// <style>-Element dort ist also garantiert seitenEIGEN und darf beim
|
|
// Wechsel 1:1 ersetzt werden.
|
|
document.head.querySelectorAll("style[data-seiteneigen]").forEach((el) => el.remove());
|
|
dok.head.querySelectorAll("style").forEach((el) => {
|
|
const neu = document.createElement("style");
|
|
neu.setAttribute("data-seiteneigen", "");
|
|
neu.textContent = el.textContent;
|
|
document.head.appendChild(neu);
|
|
});
|
|
|
|
// Seiteneigenes i18n-Wörterbuch zurücksetzen — sonst könnte das der
|
|
// VORHERIGEN Seite (z.B. Startseite) fälschlich auf der neuen Seite
|
|
// weiterwirken. Hat die neue Seite ein eigenes (z.B. i18n-index.js),
|
|
// setzt skripteUebernehmen() es gleich neu.
|
|
window.I18N_PAGE = null;
|
|
|
|
// Seitenmotiv + Sonderklassen mitnehmen: Die Bildvariablen hängen am
|
|
// <body>, damit das Motiv die ganze Seite trägt — beim Wechsel also mit
|
|
// übernehmen. Bewusst die GANZE Klassenliste kopieren (nicht nur
|
|
// "mit-hintergrund" einzeln pflegen), sonst bleiben Sonderklassen wie
|
|
// "bewerben-seite" oder "start-seite" nach einem Klick-Wechsel fälschlich
|
|
// stehen bzw. fehlen auf der neuen Seite.
|
|
const bodyNeu = dok.body;
|
|
document.body.className = bodyNeu.className;
|
|
["--page-bg", "--page-bg-mobile"].forEach((v) => {
|
|
const wert = bodyNeu.style.getPropertyValue(v);
|
|
if (wert) document.body.style.setProperty(v, wert);
|
|
else document.body.style.removeProperty(v);
|
|
});
|
|
|
|
// Kopf der Seite mitziehen: Titel, Farbwelt, Beschreibung
|
|
document.title = dok.title;
|
|
const themaNeu = dok.documentElement.getAttribute("data-theme");
|
|
if (themaNeu) document.documentElement.setAttribute("data-theme", themaNeu);
|
|
const beschreibungNeu = dok.querySelector('meta[name="description"]');
|
|
const beschreibungAlt = document.querySelector('meta[name="description"]');
|
|
if (beschreibungNeu && beschreibungAlt) {
|
|
beschreibungAlt.setAttribute("content", beschreibungNeu.getAttribute("content"));
|
|
}
|
|
|
|
if (mitVerlauf) history.pushState({ nahtlos: true }, "", url);
|
|
currentFile = (new URL(url, location.href).pathname.split("/").pop() || "index.html");
|
|
|
|
renderHeader(); // markiert den richtigen Menüpunkt als aktiv
|
|
window.scrollTo(0, 0);
|
|
document.body.classList.remove("seite-laedt");
|
|
musikKnopfFarbenAnpassen(); // Chamäleon-Knopf an die neue Seite anpassen
|
|
|
|
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() {
|
|
// Ohne History-API oder auf file:// lieber gar nicht erst versuchen
|
|
if (!window.history || !window.history.pushState || location.protocol === "file:") return;
|
|
if (KEIN_NAHTLOSER_WECHSEL.includes(currentFile)) return;
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (e.defaultPrevented || e.button !== 0) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // neuer Tab o.ä.
|
|
|
|
const link = e.target.closest("a");
|
|
if (!link) return;
|
|
|
|
const href = link.getAttribute("href");
|
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return;
|
|
if (link.target && link.target !== "_self") return;
|
|
if (link.hasAttribute("download")) return;
|
|
|
|
const ziel = new URL(href, location.href);
|
|
if (ziel.origin !== location.origin) return; // fremde Seite
|
|
if (!ziel.pathname.endsWith(".html")) return; // z.B. Bilddatei
|
|
const datei = ziel.pathname.split("/").pop();
|
|
if (KEIN_NAHTLOSER_WECHSEL.includes(datei)) return; // interner Bereich
|
|
if (ziel.pathname === location.pathname && ziel.search === location.search) return;
|
|
|
|
e.preventDefault();
|
|
seiteLaden(ziel.href, true);
|
|
});
|
|
|
|
window.addEventListener("popstate", () => {
|
|
// Zurück-/Vorwärts-Knopf im Browser
|
|
const datei = location.pathname.split("/").pop() || "index.html";
|
|
if (KEIN_NAHTLOSER_WECHSEL.includes(datei)) {
|
|
location.reload();
|
|
return;
|
|
}
|
|
seiteLaden(location.href, false);
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
// Das <style>, das die ERSTE Seite selbst mitbringt (roh aus dem HTML
|
|
// geparst, noch ohne unsere Markierung), muss dieselbe Markierung wie
|
|
// die später per SPA-Wechsel nachgeladenen bekommen — sonst würde
|
|
// seiteLaden() es nie wieder entfernen können und es bliebe für immer
|
|
// im <head> hängen, sobald man einmal weiterklickt (siehe Kommentar
|
|
// dort für den vollen Zusammenhang).
|
|
document.head.querySelectorAll("style:not([data-seiteneigen])").forEach((el) => el.setAttribute("data-seiteneigen", ""));
|
|
renderHeader();
|
|
renderFooter();
|
|
renderFog();
|
|
renderMusicWidget();
|
|
// 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
|
|
(z.B. der Live-Status auf index.html), die außerhalb dieser IIFE
|
|
ebenfalls die aktuelle Sprache übersetzen wollen. */
|
|
window.dogiSprache = getSprache;
|
|
window.dogiUebersetzen = function (schluessel, ersetzungen) {
|
|
const eintrag = (window.I18N_PAGE || {})[schluessel];
|
|
if (!eintrag) return schluessel;
|
|
const sprache = getSprache();
|
|
let text = eintrag[sprache] || eintrag.de || schluessel;
|
|
if (ersetzungen) {
|
|
Object.keys(ersetzungen).forEach((platzhalter) => {
|
|
text = text.replace(`{${platzhalter}}`, ersetzungen[platzhalter]);
|
|
});
|
|
}
|
|
return text;
|
|
};
|
|
|
|
/* Für mehrsprachige Personendaten-Felder (z.B. role/bio/bioHtml/quote/
|
|
intro in data-modis.js, data-scouts.js, data-creator.js). Ein Feld
|
|
ist entweder weiterhin ein reiner String (rückwärtskompatibel, z.B.
|
|
leere Zitate "") oder ein Objekt { de, "de-CH", en, fr, pt }. Fehlt
|
|
die aktuelle Sprache, wird auf Deutsch zurückgefallen. */
|
|
window.dogiFeld = function (feld) {
|
|
if (feld == null) return "";
|
|
if (typeof feld === "string") return feld;
|
|
const sprache = getSprache();
|
|
return feld[sprache] || feld.de || "";
|
|
};
|
|
|
|
/* =====================================================================
|
|
dogiTeilen() — 21.08.2026, Nutzer-Wunsch: "die events des jahres und
|
|
speziellen live events soll man auch teilen können." Wiederverwendbare
|
|
Teilen-Funktion für einzelne Inhalte (aktuell: Event des Jahres auf
|
|
index.html, spezielles Live-Event auf streamplan.html — künftig bei
|
|
Bedarf für weitere Stellen nutzbar, ohne den Mechanismus neu zu bauen).
|
|
|
|
Nutzt zuerst die native Teilen-Funktion des Geräts (Web-Share-API) —
|
|
auf dem Handy öffnet das den echten Teilen-Dialog (WhatsApp, TikTok,
|
|
Mail, ...). Die meisten Desktop-Browser kennen diese API nicht/zeigen
|
|
dort ohnehin kein sinnvolles Menü -- dort stattdessen Fallback: Link in
|
|
die Zwischenablage kopieren, der klickende Knopf bekommt kurz ein
|
|
"✓ Link kopiert"-Feedback (eigener Text + eigene Klasse, gleiches
|
|
Timing wie .vw-copy-btn in der Verwaltung). */
|
|
window.dogiTeilen = async function (daten, knopf) {
|
|
const payload = { title: daten.title || "", text: daten.text || "", url: daten.url || location.href };
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share(payload);
|
|
return;
|
|
} catch (err) {
|
|
if (err && err.name === "AbortError") return; // Dialog selbst abgebrochen -- kein Fallback nötig
|
|
// sonst: weiter zum Zwischenablage-Fallback unten
|
|
}
|
|
}
|
|
if (!navigator.clipboard) return;
|
|
try {
|
|
await navigator.clipboard.writeText(payload.url);
|
|
if (knopf) {
|
|
const alterText = knopf.textContent;
|
|
knopf.textContent = "✓ Link kopiert";
|
|
knopf.classList.add("dogi-teilen-kopiert");
|
|
setTimeout(() => {
|
|
knopf.textContent = alterText;
|
|
knopf.classList.remove("dogi-teilen-kopiert");
|
|
}, 1600);
|
|
}
|
|
} catch {
|
|
/* Zwischenablage nicht erreichbar (z.B. kein HTTPS-Kontext) -- kein
|
|
Blocker, der Link bleibt über die normale Adressleiste erreichbar. */
|
|
}
|
|
};
|
|
|
|
/* =====================================================================
|
|
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
|
|
installieren" anbietet (Icon landet dann auf dem Desktop/Startmenü).
|
|
Bewusst defensiv: registriert NUR über http(s), niemals bei file://
|
|
(dort schlägt die Registrierung ohnehin fehl, die Seite muss aber
|
|
laut Projektregel auch per Doppelklick funktionieren — deshalb kein
|
|
ungefangener Fehler in der Konsole). ===================================================================== */
|
|
if ("serviceWorker" in navigator && /^https?:$/.test(location.protocol)) {
|
|
window.addEventListener("load", () => {
|
|
navigator.serviceWorker.register("/sw.js").catch(() => {
|
|
/* Stiller Fallback — Seite funktioniert auch ohne Service Worker,
|
|
nur ohne Installierbarkeit/Offline-Cache. */
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ===================================================================
|
|
PWA: apple-touch-icon nachtragen (19.08.2026)
|
|
iOS/iPadOS ignorieren das Web-App-Manifest für das Startbildschirm-
|
|
Symbol vollständig und lesen AUSSCHLIESSLICH <link rel="apple-touch-icon">.
|
|
Dieses Tag fehlte im gesamten Projekt — auf dem iPhone bekam man beim
|
|
"Zum Home-Bildschirm" deshalb einen unscharfen Seiten-Screenshot statt
|
|
des Logos. Hier zentral injiziert statt in alle 34 HTML-Dateien
|
|
einzeln kopiert (gate.html hat es statisch, weil sie main.js bewusst
|
|
nicht lädt). iOS wertet das Tag erst beim Antippen von "Zum Home-
|
|
Bildschirm" aus — zu dem Zeitpunkt ist dieses Skript längst gelaufen,
|
|
das Nachtragen per JS ist daher zuverlässig.
|
|
===================================================================== */
|
|
if (!document.querySelector('link[rel="apple-touch-icon"]')) {
|
|
const appleIcon = document.createElement("link");
|
|
appleIcon.rel = "apple-touch-icon";
|
|
appleIcon.href = "/assets/img/apple-touch-icon.png";
|
|
document.head.appendChild(appleIcon);
|
|
}
|
|
if (!document.querySelector('meta[name="apple-mobile-web-app-title"]')) {
|
|
const appleTitel = document.createElement("meta");
|
|
appleTitel.name = "apple-mobile-web-app-title";
|
|
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";
|
|
});
|
|
})();
|