Seed-Daten per Fetch von externem Server laden, UI-Sprachumschalter (DE/EN) und KI-Übersetzungs-Prompt-Generator ergänzen

- Beispieldaten werden nicht mehr eingebettet (data/seed.js entfernt),
  sondern beim ersten Start per fetch(EventMap.config.SEED_DATA_URL) von
  einem externen, selbst gehosteten Server geladen (siehe ADR 0001,
  Revision 2026-07-28).
- Neuer UI-Sprachumschalter (Deutsch/Englisch) für die Bedienoberfläche:
  js/i18n/strings.js + js/i18n/i18n.js, Sprachwahl im Menü, Persistenz über
  settings.language, Retranslation ohne Reload.
- Neuer Menüpunkt "Ausflugsziel übersetzen (KI)": erzeugt einen Prompt, der
  eine externe KI bittet, Name/Beschreibungen eines bestehenden Ortes ins
  Englische, Spanische oder Französische zu übersetzen (js/ai/translatePromptBuilder.js,
  js/ui/translatePromptGenerator.js).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:16:31 +02:00
co-authored by Claude Sonnet 5
parent 4eb595aa81
commit 2aaff8f680
17 changed files with 1027 additions and 2007 deletions
+138
View File
@@ -0,0 +1,138 @@
// Laufzeit-i18n-Engine für den UI-Sprachumschalter (ADR 0002). Übersetzt die
// Bedienoberfläche (Buttons, Labels, Statusmeldungen) live, ohne Reload und
// ohne Build-Tooling - passend zur file://-Anforderung aus ADR 0001. Die
// Sprachwahl wird über EventMap.store (settings.language) persistiert.
//
// Setzt EventMap.i18n.STRINGS/SUPPORTED_LANGUAGES aus js/i18n/strings.js
// voraus (muss davor geladen sein) sowie EventMap.store (muss vor dieser
// Datei in index.html eingebunden sein, da resolveInitialLanguage() bereits
// beim Init synchron auf die persistierten Settings zugreift).
(function () {
const { STRINGS, SUPPORTED_LANGUAGES } = EventMap.i18n;
let currentLanguage = 'de';
const languageChangeListeners = new Set();
const warnedMissingKeys = new Set();
function lookup(lang, key) {
let node = STRINGS[lang];
for (const part of key.split('.')) {
if (node == null) return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
function interpolate(str, params) {
if (!params) return str;
return str.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? params[name] : match));
}
/** Übersetzt einen dotted Key (z.B. "menu.locate"). Fallback-Kette: aktuelle Sprache -> Deutsch -> Key selbst. */
function t(key, params) {
let value = lookup(currentLanguage, key);
if (value === undefined) {
value = lookup('de', key);
}
if (value === undefined) {
if (!warnedMissingKeys.has(key)) {
warnedMissingKeys.add(key);
console.warn(`EventMap.i18n: Übersetzungsschlüssel "${key}" nicht gefunden.`);
}
return key;
}
return interpolate(value, params);
}
function getLanguage() {
return currentLanguage;
}
/** Übersetzt alle [data-i18n]/[data-i18n-aria-label]/[data-i18n-placeholder]/[data-i18n-title]-Knoten unterhalb von root. */
function applyStaticTranslations(root) {
const scope = root || document;
scope.querySelectorAll('[data-i18n]').forEach((node) => {
node.textContent = t(node.getAttribute('data-i18n'));
});
scope.querySelectorAll('[data-i18n-aria-label]').forEach((node) => {
node.setAttribute('aria-label', t(node.getAttribute('data-i18n-aria-label')));
});
scope.querySelectorAll('[data-i18n-placeholder]').forEach((node) => {
node.setAttribute('placeholder', t(node.getAttribute('data-i18n-placeholder')));
});
scope.querySelectorAll('[data-i18n-title]').forEach((node) => {
node.setAttribute('title', t(node.getAttribute('data-i18n-title')));
});
document.documentElement.lang = currentLanguage;
}
/** Registriert einen Listener, der bei jedem Sprachwechsel aufgerufen wird. Gibt eine Unsubscribe-Funktion zurück. */
function onLanguageChange(fn) {
languageChangeListeners.add(fn);
return () => languageChangeListeners.delete(fn);
}
function notifyLanguageChange() {
for (const fn of languageChangeListeners) {
fn(currentLanguage);
}
}
/**
* Ermittelt die initiale Sprache: persistierte Einstellung falls vorhanden,
* sonst Browsersprache (navigator.languages), sonst Deutsch als Fallback.
*/
function resolveInitialLanguage() {
const settings = EventMap.store.getSettings();
if (settings.language && SUPPORTED_LANGUAGES.includes(settings.language)) {
return settings.language;
}
const browserLanguages = navigator.languages && navigator.languages.length
? navigator.languages
: [navigator.language];
for (const lang of browserLanguages) {
const prefix = (lang || '').slice(0, 2).toLowerCase();
if (SUPPORTED_LANGUAGES.includes(prefix)) {
return prefix;
}
}
return 'de';
}
/** Wechselt die Anzeigesprache: persistiert, übersetzt statisches HTML neu und benachrichtigt Listener. */
function setLanguage(lang) {
if (!SUPPORTED_LANGUAGES.includes(lang) || lang === currentLanguage) {
return;
}
currentLanguage = lang;
EventMap.store.updateSettings({ language: lang });
applyStaticTranslations(document);
notifyLanguageChange();
// Stößt bei allen Store-Subscribern (Liste, Filter-Panel, ...) einen
// Re-Render an, ohne dass sich der eigentliche Datenzustand ändert -
// siehe ADR 0002, Abschnitt "Re-Render-Pfad".
EventMap.store.setState({});
}
/** Ermittelt die Startsprache und übersetzt das initiale statische HTML. Wird als Erstes in main.js:bootstrap() aufgerufen. */
function init() {
currentLanguage = resolveInitialLanguage();
applyStaticTranslations(document);
}
EventMap.i18n.t = t;
EventMap.i18n.getLanguage = getLanguage;
EventMap.i18n.setLanguage = setLanguage;
EventMap.i18n.onLanguageChange = onLanguageChange;
EventMap.i18n.applyStaticTranslations = applyStaticTranslations;
EventMap.i18n.resolveInitialLanguage = resolveInitialLanguage;
EventMap.i18n.init = init;
})();