Kleine Kugel im Sprachschalter nimmt jetzt die Landesfarben der aktuell gewählten Sprache als Verlauf an (DE: schwarz-rot-gold, CH: rot/weiß, EN: blau-weiß-rot, FR: blau-weiß-rot, PT: grün-rot) - mit Glanzlicht/ Schatten für einen echten Kugel-Look. Selbe Kugel auch vor jedem Eintrag im Dropdown, damit die Sprachen auf einen Blick unterscheidbar sind.
813 lines
38 KiB
JavaScript
813 lines
38 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", farben: "linear-gradient(160deg, #1a1a1a 0%, #dd0000 50%, #ffce00 100%)" },
|
|
{ code: "de-CH", kurz: "CH", name: "Deutsch (Schweiz)", farben: "linear-gradient(160deg, #d52b1e 0%, #d52b1e 60%, #ffffff 100%)" },
|
|
{ code: "en", kurz: "EN", name: "English", farben: "linear-gradient(160deg, #00247d 0%, #ffffff 50%, #cf142b 100%)" },
|
|
{ code: "fr", kurz: "FR", name: "Français", farben: "linear-gradient(160deg, #0055a4 0%, #ffffff 50%, #ef4135 100%)" },
|
|
{ code: "pt", kurz: "PT", name: "Português", farben: "linear-gradient(160deg, #046a38 0%, #046a38 45%, #da291c 100%)" },
|
|
];
|
|
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")}">
|
|
<span class="sprach-kugel" style="background:${aktiv.farben};" aria-hidden="true"></span>
|
|
${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"' : ""}><span class="sprach-kugel" style="background:${s.farben};" aria-hidden="true"></span>${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&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;
|
|
};
|
|
})();
|