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:
+138
@@ -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;
|
||||
})();
|
||||
@@ -0,0 +1,108 @@
|
||||
// Übersetzungstabelle für die App-Oberfläche (UI-Sprachumschalter, Phase A
|
||||
// laut ADR 0002). Reine Datendatei ohne Logik - die Auflösung/Fallback-Kette
|
||||
// übernimmt js/i18n/i18n.js.
|
||||
//
|
||||
// WICHTIG: Dies ist von EventMap.ai.buildPrompt()/buildTranslationPrompt()
|
||||
// (js/ai/promptBuilder.js, js/ai/translatePromptBuilder.js) komplett getrennt.
|
||||
// Dort geht es um die Sprache, in der eine externe KI Ortsbeschreibungen
|
||||
// verfasst/übersetzt (Datenebene) - hier um die Sprache der Bedienoberfläche
|
||||
// selbst (UI-Ebene). Nicht vermischen.
|
||||
|
||||
window.EventMap = window.EventMap || {};
|
||||
window.EventMap.i18n = window.EventMap.i18n || {};
|
||||
|
||||
(function () {
|
||||
const STRINGS = {
|
||||
de: {
|
||||
documentTitle: 'EventMap – Familienfreundliche Ausflugsziele',
|
||||
header: {
|
||||
title: 'EventMap',
|
||||
subtitle: 'Familienfreundliche Ausflugsziele auf der Karte',
|
||||
menuToggleLabel: 'Menü öffnen',
|
||||
},
|
||||
settings: {
|
||||
languageLabel: 'Anzeigesprache',
|
||||
},
|
||||
menu: {
|
||||
ariaLabel: 'Aktionen',
|
||||
locate: 'Meinen Standort verwenden',
|
||||
locating: 'Standort wird ermittelt…',
|
||||
manualLocation: 'Standort manuell eingeben',
|
||||
pickLocation: '📍 Standort auf Karte wählen',
|
||||
pickLocationActive: 'Klicke auf die Karte …',
|
||||
addPlace: '+ Neues Ausflugsziel hinzufügen',
|
||||
promptGenerator: '🤖 Neue Ausflugsziele mit KI erstellen',
|
||||
translatePlace: '🌐 Ausflugsziel übersetzen (KI)',
|
||||
import: '📥 KI-Ausflugsziele importieren',
|
||||
backup: '💾 Daten sichern/wiederherstellen',
|
||||
},
|
||||
status: {
|
||||
closeLabel: 'Meldung schließen',
|
||||
geolocationError: 'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.',
|
||||
seedLoadFailed: 'Beispieldaten konnten nicht geladen werden. Du kannst trotzdem eigene Ausflugsziele hinzufügen.',
|
||||
bootstrapFailed: 'Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.',
|
||||
},
|
||||
map: {
|
||||
ariaLabel: 'Karte',
|
||||
loading: 'Karte wird geladen…',
|
||||
pickingHint: 'Klicke auf die Karte, um den Standort auszuwählen.',
|
||||
pickingCancel: 'Abbrechen',
|
||||
},
|
||||
filterPanel: {
|
||||
ariaLabel: 'Filter und Suche',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Ergebnisliste',
|
||||
title: 'Ausflugsziele',
|
||||
loading: 'Lade Ausflugsziele…',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
documentTitle: 'EventMap – Family-friendly outing destinations',
|
||||
header: {
|
||||
title: 'EventMap',
|
||||
subtitle: 'Family-friendly outing destinations on the map',
|
||||
menuToggleLabel: 'Open menu',
|
||||
},
|
||||
settings: {
|
||||
languageLabel: 'Display language',
|
||||
},
|
||||
menu: {
|
||||
ariaLabel: 'Actions',
|
||||
locate: 'Use my location',
|
||||
locating: 'Locating…',
|
||||
manualLocation: 'Enter location manually',
|
||||
pickLocation: '📍 Pick location on map',
|
||||
pickLocationActive: 'Click on the map …',
|
||||
addPlace: '+ Add new destination',
|
||||
promptGenerator: '🤖 Create new destinations with AI',
|
||||
translatePlace: '🌐 Translate destination (AI)',
|
||||
import: '📥 Import AI destinations',
|
||||
backup: '💾 Backup/restore data',
|
||||
},
|
||||
status: {
|
||||
closeLabel: 'Dismiss message',
|
||||
geolocationError: 'Could not determine your location. Please check location permissions in your browser.',
|
||||
seedLoadFailed: 'Sample data could not be loaded. You can still add your own destinations.',
|
||||
bootstrapFailed: 'The application could not be loaded. Please reload the page.',
|
||||
},
|
||||
map: {
|
||||
ariaLabel: 'Map',
|
||||
loading: 'Loading map…',
|
||||
pickingHint: 'Click on the map to choose the location.',
|
||||
pickingCancel: 'Cancel',
|
||||
},
|
||||
filterPanel: {
|
||||
ariaLabel: 'Filter and search',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Results list',
|
||||
title: 'Destinations',
|
||||
loading: 'Loading destinations…',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
EventMap.i18n.STRINGS = STRINGS;
|
||||
EventMap.i18n.SUPPORTED_LANGUAGES = Object.keys(STRINGS);
|
||||
})();
|
||||
Reference in New Issue
Block a user