Files
dogfather-universe/assets/js/main.js
T
DogFatherGit dae0537e1e Sprachumschalter: Flaggen-Emojis durch Globus-Symbol + Klarnamen ersetzt
Windows rendert Flaggen-Emojis nicht als Bild, sondern zeigt den nackten
Regions-Code an (z.B. "DE") — zusammen mit dem eigenen Kürzel ergab das
eine unschöne Dopplung ("DE DE") bzw. bei Englisch sogar einen falschen
Ländercode (🇬🇧 = GB, nicht EN). Jetzt: einzelnes 🌐-Symbol im Knopf,
volle Sprachnamen im Dropdown (Deutsch, Deutsch (Schweiz), English,
Français, Português) - eindeutig auf jedem Betriebssystem.
2026-08-02 00:35:23 +02:00

810 lines
37 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, Schweizer Hochdeutsch (ß→ss, sonst wie Deutsch),
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. */
const SPRACHEN = [
{ code: "de", kurz: "DE", name: "Deutsch" },
{ code: "de-CH", kurz: "CH", name: "Deutsch (Schweiz)" },
{ code: "en", kurz: "EN", name: "English" },
{ code: "fr", kurz: "FR", name: "Français" },
{ code: "pt", kurz: "PT", name: "Português" },
];
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 = {
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" },
nav_dogfather_person: { de: "Die Person", "de-CH": "Die Person", en: "The Person", fr: "La personne", pt: "A pessoa" },
nav_dogfather_streamer: { de: "Streamer", "de-CH": "Streamer", en: "Streamer", fr: "Streamer", pt: "Streamer" },
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": "Zeitreise", en: "Timeline", fr: "Chronologie", pt: "Linha do tempo" },
nav_hasidog: { de: "HasiDog", "de-CH": "HasiDog", en: "HasiDog", fr: "HasiDog", pt: "HasiDog" },
nav_hasidog_welt: { de: "HasiDog-Welt", "de-CH": "HasiDog-Welt", en: "HasiDog World", fr: "Monde HasiDog", pt: "Mundo 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": "Stimmen", 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": "Werte", en: "Values", fr: "Valeurs", pt: "Valores" },
nav_manager_bewerben: { de: "Bewerben", "de-CH": "Bewerben", en: "Apply", fr: "Postuler", pt: "Candidatar-se" },
nav_medien: { de: "Medien", "de-CH": "Medien", 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": "Bewerben 🩵", en: "Apply 🩵", fr: "Postuler 🩵", pt: "Candidatar-se 🩵" },
nav_toggle_aria: { de: "Menü öffnen", "de-CH": "Menü öffnen", en: "Open menu", fr: "Ouvrir le menu", pt: "Abrir menu" },
sprache_aria: { de: "Sprache wählen", "de-CH": "Sprache wählen", 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. Gemeinsam stark.",
en: "Streamer. Community. HasiDog. Manager. Stronger together.",
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" },
footer_h4_mehr: { de: "Mehr", "de-CH": "Mehr", 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": "Alle 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 ist 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" },
music_title: { de: "Musik von Team Dogi 🎵", "de-CH": "Musik von 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": "Musik-Feld schliessen", 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": "Läuft weiter, solange du auf dieser Seite bleibst. 🩵",
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": "Musik von 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": "auf Spotify anhören", 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);
});
});
}
/* ---------- 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_streamer", href: "streamer.html" },
{ key: "nav_dogfather_casper", href: "casper.html" },
{ key: "nav_dogfather_vanvan", href: "vanvan.html" },
{ key: "nav_dogfather_zeitreise", href: "zeitreise.html" },
],
},
{
key: "nav_hasidog",
children: [
{ 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;
}
function renderNavItem(item) {
if (!item.children) {
return `<a class="nav-flat${isActive(item.href) ? " active" : ""}" href="${item.href}">${t(item.key)}</a>`;
}
const activeChild = item.children.some((c) => isActive(c.href));
return `
<div class="nav-group${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. */
function renderSprachSchalter() {
const aktuell = getSprache();
const aktiv = SPRACHEN.find((s) => s.code === aktuell) || SPRACHEN[0];
return `
<div class="nav-group sprach-schalter">
<button type="button" aria-expanded="false" aria-label="${t("sprache_aria")}">🌐 ${aktiv.kurz} <span aria-hidden="true">▾</span></button>
<div class="nav-dropdown">
${SPRACHEN.map(
(s) =>
`<a href="#" data-sprache="${s.code}"${s.code === aktuell ? ' class="active"' : ""}>${s.name}</a>`
).join("")}
</div>
</div>`;
}
function renderHeader() {
const el = document.getElementById("site-header");
if (!el) return;
el.innerHTML = `
<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">☰</button>
<nav class="nav-links">
${NAV.map(renderNavItem).join("")}
${renderSprachSchalter()}
<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));
});
});
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>
</div>
<div>
<h4>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="zeitreise.html">${t("nav_dogfather_zeitreise")}</a></li>
</ul>
</div>
<div>
<h4>${t("footer_h4_hasidog_manager")}</h4>
<ul>
<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></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&amp;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.
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);
// 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
}
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", () => {
renderHeader();
renderFooter();
renderFog();
renderMusicWidget();
musikKnopfFarbenAnpassen();
nahtloseNavigationAktivieren();
applyTranslations(); // seiteneigene data-i18n-Elemente (z.B. Startseite) übersetzen
});
/* 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;
};
})();