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:
@@ -0,0 +1,81 @@
|
||||
// Reine Funktion zur Erzeugung eines Übersetzungs-Prompts für ein bestehendes
|
||||
// Ausflugsziel. Ergänzung zu js/ai/promptBuilder.js (das neue Ausflugsziele
|
||||
// recherchiert) - hier wird stattdessen ein einzelnes, bereits vorhandenes
|
||||
// Place-Objekt an eine externe KI übergeben, mit der Bitte, name/
|
||||
// shortDescription/description in eine Zielsprache zu übersetzen (Englisch/
|
||||
// Spanisch/Französisch) und als JSON zurückzuliefern, das direkt in das
|
||||
// "translations"-Feld des Datenmodells passt (siehe js/models/schema.js,
|
||||
// js/models/place.js).
|
||||
//
|
||||
// Getrennt von EventMap.i18n (UI-Sprachumschalter, js/i18n/*.js): dort geht
|
||||
// es um die Sprache der App-Oberfläche, hier um die Sprache von
|
||||
// Ortsinhalten, die von einer externen KI übersetzt werden - beide Konzepte
|
||||
// bewusst nicht vermischt.
|
||||
|
||||
(function () {
|
||||
const TARGET_LANGUAGE_LABELS = {
|
||||
en: 'Englisch (English)',
|
||||
es: 'Spanisch (Español)',
|
||||
fr: 'Französisch (Français)',
|
||||
};
|
||||
|
||||
function buildTranslationJsonTemplate(place, targetLanguage) {
|
||||
return `{
|
||||
"id": "${place.id}",
|
||||
"translations": {
|
||||
"${targetLanguage}": {
|
||||
"name": "...",
|
||||
"shortDescription": "...",
|
||||
"description": "..."
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} place Bestehendes Place-Objekt (id/name/shortDescription/description werden gelesen)
|
||||
* @param {'en'|'es'|'fr'} targetLanguage
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildTranslationPrompt(place, targetLanguage) {
|
||||
const label = TARGET_LANGUAGE_LABELS[targetLanguage] || targetLanguage;
|
||||
|
||||
const sourceText = JSON.stringify(
|
||||
{
|
||||
name: place.name,
|
||||
shortDescription: place.shortDescription,
|
||||
description: place.description,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
const sections = [
|
||||
'Du bist ein sorgfältiger Übersetzer für Beschreibungen familienfreundlicher Ausflugsziele.',
|
||||
`Übersetze die folgenden drei Textfelder eines Ausflugsziels ins ${label}. Übersetze inhaltlich ` +
|
||||
'korrekt und natürlich klingend, ohne neue Informationen zu erfinden oder bestehende Informationen ' +
|
||||
'zu verändern. Eigennamen (z.B. Orts- und Straßennamen) bleiben unverändert, sofern im ' +
|
||||
'Zielsprachenraum kein gebräuchlicher, etablierter Name existiert.',
|
||||
'',
|
||||
'1. Ausgangstext (Deutsch)',
|
||||
'```json',
|
||||
sourceText,
|
||||
'```',
|
||||
'',
|
||||
'2. Ausgabeformat',
|
||||
'Antworte ausschließlich mit einem einzigen, gültigen JSON-Objekt - ohne einleitenden oder ' +
|
||||
'abschließenden Fließtext, ohne Markdown-Codeblock-Auszeichnung außerhalb des JSON und ohne ' +
|
||||
'Kommentare innerhalb des JSON. Das Feld "id" muss exakt wie unten vorgegeben übernommen werden.',
|
||||
'',
|
||||
'Zielformat (bitte exakt einhalten, "..." durch die übersetzten Texte ersetzen)',
|
||||
'```json',
|
||||
buildTranslationJsonTemplate(place, targetLanguage),
|
||||
'```',
|
||||
];
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
EventMap.ai.buildTranslationPrompt = buildTranslationPrompt;
|
||||
EventMap.ai.TARGET_LANGUAGE_LABELS = TARGET_LANGUAGE_LABELS;
|
||||
})();
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
// Beispieldaten fuer den allerersten App-Start (leerer LocalStorage) werden
|
||||
// von dort per fetch() nachgeladen statt im Repo eingebettet zu sein.
|
||||
const SEED_DATA_URL = 'https://web.hhml.selfhost.co/eventmap_seed.json';
|
||||
|
||||
// Geografische Mitte Deutschlands als Standard-Kartenzentrum,
|
||||
// solange der Nutzer keinen eigenen Standort freigibt oder ein
|
||||
// eigenes defaultCenter in den Settings hinterlegt hat.
|
||||
@@ -20,4 +24,5 @@
|
||||
EventMap.config.STORAGE_KEYS = STORAGE_KEYS;
|
||||
EventMap.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
|
||||
EventMap.config.DEFAULT_MAP_CENTER = DEFAULT_MAP_CENTER;
|
||||
EventMap.config.SEED_DATA_URL = SEED_DATA_URL;
|
||||
})();
|
||||
|
||||
+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);
|
||||
})();
|
||||
+36
-14
@@ -98,28 +98,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupLanguageSwitcher() {
|
||||
const select = document.getElementById('language-select');
|
||||
if (!select) return;
|
||||
|
||||
select.value = EventMap.i18n.getLanguage();
|
||||
select.addEventListener('change', () => {
|
||||
EventMap.i18n.setLanguage(select.value);
|
||||
});
|
||||
}
|
||||
|
||||
function setupGeolocationButton() {
|
||||
const button = document.getElementById('locate-btn');
|
||||
if (!button) return;
|
||||
|
||||
const defaultLabel = button.textContent;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Standort wird ermittelt…';
|
||||
button.textContent = EventMap.i18n.t('menu.locating');
|
||||
|
||||
requestUserLocation({
|
||||
onSuccess: ({ lat, lng }) => {
|
||||
centerOnCoords(lat, lng, 13);
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
button.textContent = EventMap.i18n.t('menu.locate');
|
||||
},
|
||||
onError: () => {
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
showFatalError(
|
||||
'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.'
|
||||
);
|
||||
button.textContent = EventMap.i18n.t('menu.locate');
|
||||
showFatalError(EventMap.i18n.t('status.geolocationError'));
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -140,16 +146,14 @@
|
||||
const cancelBtn = document.getElementById('map-picking-cancel-btn');
|
||||
if (!button || !hint || !cancelBtn) return;
|
||||
|
||||
const defaultLabel = button.textContent;
|
||||
|
||||
function endPicking() {
|
||||
button.textContent = defaultLabel;
|
||||
button.textContent = EventMap.i18n.t('menu.pickLocation');
|
||||
button.classList.remove('btn--active');
|
||||
hint.hidden = true;
|
||||
}
|
||||
|
||||
function beginPicking() {
|
||||
button.textContent = 'Klicke auf die Karte …';
|
||||
button.textContent = EventMap.i18n.t('menu.pickLocationActive');
|
||||
button.classList.add('btn--active');
|
||||
hint.hidden = false;
|
||||
|
||||
@@ -196,6 +200,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupTranslatePromptGeneratorButton() {
|
||||
const button = document.getElementById('open-translate-prompt-generator-btn');
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
EventMap.ui.openTranslatePromptGenerator();
|
||||
});
|
||||
}
|
||||
|
||||
function setupImportButton() {
|
||||
const button = document.getElementById('open-import-btn');
|
||||
if (!button) return;
|
||||
@@ -215,7 +228,10 @@
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await initStore();
|
||||
EventMap.i18n.init();
|
||||
setupLanguageSwitcher();
|
||||
|
||||
const { seedLoadFailed } = await initStore();
|
||||
initMap('map');
|
||||
initFilterPanel('filter-panel');
|
||||
initList('place-list');
|
||||
@@ -223,14 +239,20 @@
|
||||
initForm('place-form-modal');
|
||||
initManualLocationForm('manual-location-modal');
|
||||
initPromptGenerator('prompt-generator-modal');
|
||||
EventMap.ui.initTranslatePromptGenerator('translate-prompt-generator-modal');
|
||||
initImport('import-modal');
|
||||
setupGeolocationButton();
|
||||
setupManualLocationButton();
|
||||
setupPickLocationButton();
|
||||
setupAddPlaceButton();
|
||||
setupPromptGeneratorButton();
|
||||
setupTranslatePromptGeneratorButton();
|
||||
setupImportButton();
|
||||
setupBackupButton();
|
||||
|
||||
if (seedLoadFailed) {
|
||||
showFatalError(EventMap.i18n.t('status.seedLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
setupStatusClose();
|
||||
@@ -238,6 +260,6 @@
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('EventMap: Initialisierung fehlgeschlagen.', err);
|
||||
showFatalError('Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.');
|
||||
showFatalError(EventMap.i18n.t('status.bootstrapFailed'));
|
||||
});
|
||||
})();
|
||||
|
||||
+3
-2
@@ -19,8 +19,9 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert ein rohes Place-Objekt (z.B. aus data/seed.js oder einem
|
||||
* späteren KI-Import) zu einem vollständigen internen Place-Objekt.
|
||||
* Normalisiert ein rohes Place-Objekt (z.B. aus den per SEED_DATA_URL
|
||||
* geladenen Beispieldaten oder einem späteren KI-Import) zu einem
|
||||
* vollständigen internen Place-Objekt.
|
||||
* Fehlende Felder werden mit Defaults aufgefüllt, id/createdAt/updatedAt
|
||||
* werden übernommen falls vorhanden, sonst neu generiert.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
lastMapView: null,
|
||||
userConsentGeo: null,
|
||||
defaultCenter: null,
|
||||
language: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+26
-13
@@ -1,7 +1,8 @@
|
||||
// Zentraler Pub/Sub-Store der Anwendung. Hält den Laufzeitzustand
|
||||
// (places, filters, userLocation, selectedPlaceId) und benachrichtigt
|
||||
// Subscriber bei Änderungen. Lädt beim Start aus LocalStorage bzw. beim
|
||||
// allerersten Start aus data/seed.js und persistiert Änderungen zurück.
|
||||
// allerersten Start von EventMap.config.SEED_DATA_URL und persistiert
|
||||
// Änderungen zurück.
|
||||
|
||||
(function () {
|
||||
const { loadData, saveData, loadSettings, saveSettings } = EventMap.store;
|
||||
@@ -48,29 +49,41 @@
|
||||
saveSettings(settings);
|
||||
}
|
||||
|
||||
function loadSeedPlaces() {
|
||||
const raw = (EventMap.data && EventMap.data.SEED_PLACES) || [];
|
||||
/** Lädt die Beispieldaten von EventMap.config.SEED_DATA_URL nach. Wirft bei Netzwerk-/HTTP-/Parse-Fehlern. */
|
||||
async function loadSeedPlaces() {
|
||||
const response = await fetch(EventMap.config.SEED_DATA_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Seed-Daten-Download fehlgeschlagen (HTTP ${response.status}).`);
|
||||
}
|
||||
const raw = await response.json();
|
||||
return raw.map((item) => normalizePlace(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Store: lädt persistierte Places aus LocalStorage oder,
|
||||
* falls noch keine vorhanden sind, die eingebetteten Beispieldaten
|
||||
* (data/seed.js). Gibt den finalen State zurück.
|
||||
* falls noch keine vorhanden sind, die Beispieldaten von
|
||||
* EventMap.config.SEED_DATA_URL. Gibt { seedLoadFailed } zurück, damit
|
||||
* main.js bei einem fehlgeschlagenen Download einen Hinweis anzeigen kann
|
||||
* (die App bleibt dabei mit einer leeren Places-Liste nutzbar).
|
||||
*/
|
||||
function initStore() {
|
||||
async function initStore() {
|
||||
const persisted = loadData();
|
||||
let places = [];
|
||||
|
||||
if (persisted && Array.isArray(persisted.places) && persisted.places.length > 0) {
|
||||
places = persisted.places.map((p) => normalizePlace(p));
|
||||
} else {
|
||||
places = loadSeedPlaces();
|
||||
saveData(places);
|
||||
setState({ places: persisted.places.map((p) => normalizePlace(p)) });
|
||||
return { seedLoadFailed: false };
|
||||
}
|
||||
|
||||
setState({ places });
|
||||
return state;
|
||||
try {
|
||||
const places = await loadSeedPlaces();
|
||||
saveData(places);
|
||||
setState({ places });
|
||||
return { seedLoadFailed: false };
|
||||
} catch (err) {
|
||||
console.warn('EventMap: Beispieldaten konnten nicht geladen werden.', err);
|
||||
setState({ places: [] });
|
||||
return { seedLoadFailed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistiert state.places nach Änderungen (Erstellen/Bearbeiten/Löschen). */
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// "Ausflugsziel übersetzen (KI)"-Modal: lässt Nutzer ein bestehendes
|
||||
// Ausflugsziel und eine Zielsprache (Englisch/Spanisch/Französisch)
|
||||
// auswählen und erzeugt daraus per EventMap.ai.buildTranslationPrompt()
|
||||
// (js/ai/translatePromptBuilder.js) einen fertigen, kopierbaren
|
||||
// Übersetzungs-Prompt für eine externe KI.
|
||||
//
|
||||
// Aufbau/Muster bewusst analog zu js/ui/promptGenerator.js (gleiche
|
||||
// Modal-Chrome-Klassen aus css/promptGenerator.css, gleicher Copy/Download-
|
||||
// Ablauf). Rendering ausschließlich über textContent/DOM-APIs (el()).
|
||||
//
|
||||
// WICHTIG: Diese Datei erzeugt nur den Prompt. Die JSON-Antwort der KI (mit
|
||||
// dem übersetzten "translations"-Feld) muss aktuell noch manuell in die
|
||||
// Ortsdaten übernommen werden - ein automatischer Import/Merge ist (noch)
|
||||
// nicht Teil dieser Datei.
|
||||
|
||||
(function () {
|
||||
const { el, clearChildren } = EventMap.utils;
|
||||
|
||||
const TARGET_LANGUAGE_OPTIONS = [
|
||||
{ value: 'en', label: 'Englisch' },
|
||||
{ value: 'es', label: 'Spanisch' },
|
||||
{ value: 'fr', label: 'Französisch' },
|
||||
];
|
||||
|
||||
let container = null;
|
||||
let refs = null;
|
||||
|
||||
function field(id, labelText, inputNode) {
|
||||
inputNode.id = id;
|
||||
return el('div', { className: 'form-field' }, [
|
||||
el('label', { className: 'form-field__label', attrs: { for: id } }, [labelText]),
|
||||
inputNode,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildPlaceOptions() {
|
||||
const { places } = EventMap.store.getState();
|
||||
const sorted = [...places].sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de'));
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return [el('option', { attrs: { value: '' }, text: '(Keine Ausflugsziele vorhanden)' })];
|
||||
}
|
||||
|
||||
return sorted.map((place) =>
|
||||
el('option', {
|
||||
attrs: { value: place.id },
|
||||
text: place.city ? `${place.name} (${place.city})` : place.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function buildSkeleton(rootContainer) {
|
||||
refs = {};
|
||||
|
||||
refs.backdrop = el('div', {
|
||||
className: 'prompt-generator-modal__backdrop',
|
||||
on: { click: () => closeTranslatePromptGenerator() },
|
||||
});
|
||||
|
||||
refs.closeBtn = el(
|
||||
'button',
|
||||
{
|
||||
className: 'prompt-generator-modal__close',
|
||||
attrs: { type: 'button', 'aria-label': 'Übersetzungs-Prompt-Generator schließen' },
|
||||
on: { click: () => closeTranslatePromptGenerator() },
|
||||
},
|
||||
['✕']
|
||||
);
|
||||
|
||||
refs.title = el('h2', {
|
||||
className: 'prompt-generator__title',
|
||||
attrs: { id: 'translate-prompt-generator-title' },
|
||||
text: '🌐 Ausflugsziel übersetzen (KI)',
|
||||
});
|
||||
|
||||
refs.intro = el('p', {
|
||||
className: 'prompt-generator__intro',
|
||||
text:
|
||||
'Wähle ein bestehendes Ausflugsziel und eine Zielsprache aus, um einen fertigen Prompt für eine ' +
|
||||
'externe KI (z.B. ChatGPT, Claude, Gemini) zu erzeugen. Der Prompt bittet die KI, Name, Kurz- und ' +
|
||||
'ausführliche Beschreibung in die gewählte Sprache zu übersetzen.',
|
||||
});
|
||||
|
||||
refs.placeSelect = el('select', { on: { change: updateOutput } }, buildPlaceOptions());
|
||||
refs.languageSelect = el(
|
||||
'select',
|
||||
{ on: { change: updateOutput } },
|
||||
TARGET_LANGUAGE_OPTIONS.map((opt) => el('option', { attrs: { value: opt.value }, text: opt.label }))
|
||||
);
|
||||
|
||||
const form = el('div', { className: 'place-form prompt-generator__form' }, [
|
||||
el('fieldset', { className: 'place-form__section' }, [
|
||||
el('legend', { className: 'place-form__section-label', text: 'Auswahl' }),
|
||||
field('tpg-place', 'Ausflugsziel', refs.placeSelect),
|
||||
field('tpg-language', 'Zielsprache', refs.languageSelect),
|
||||
]),
|
||||
]);
|
||||
|
||||
refs.output = el('textarea', {
|
||||
className: 'prompt-generator__output-textarea',
|
||||
attrs: { readonly: '', id: 'translate-prompt-generator-output', rows: '16' },
|
||||
});
|
||||
|
||||
refs.copyBtn = el(
|
||||
'button',
|
||||
{ className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleCopyClick } },
|
||||
['In Zwischenablage kopieren']
|
||||
);
|
||||
refs.downloadBtn = el(
|
||||
'button',
|
||||
{ className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: handleDownloadClick } },
|
||||
['Als Textdatei speichern']
|
||||
);
|
||||
refs.copyStatus = el('p', { className: 'prompt-generator__status', attrs: { role: 'status' } });
|
||||
|
||||
const outputSection = el('div', { className: 'prompt-generator__output' }, [
|
||||
el('h3', { className: 'place-form__section-label', text: 'Generierter Prompt' }),
|
||||
refs.output,
|
||||
el('div', { className: 'prompt-generator__output-actions' }, [refs.copyBtn, refs.downloadBtn, refs.copyStatus]),
|
||||
el('div', { className: 'prompt-generator__instructions' }, [
|
||||
el('h3', { className: 'place-form__section-label', text: 'Anleitung' }),
|
||||
el('ol', { className: 'prompt-generator__instructions-list' }, [
|
||||
el('li', { text: 'Ausflugsziel und Zielsprache auswählen.' }),
|
||||
el('li', { text: 'Prompt kopieren oder als Textdatei speichern.' }),
|
||||
el('li', {
|
||||
text: 'In eine KI (z.B. ChatGPT, Claude, Gemini) einfügen und die Antwort abwarten.',
|
||||
}),
|
||||
el('li', {
|
||||
text:
|
||||
'Die JSON-Antwort enthält das Feld "translations" - dieses aktuell noch manuell in die ' +
|
||||
'gespeicherten Ortsdaten übernehmen (automatischer Import folgt ggf. später).',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const layout = el('div', { className: 'prompt-generator__layout' }, [form, outputSection]);
|
||||
|
||||
refs.panel = el(
|
||||
'div',
|
||||
{
|
||||
className: 'prompt-generator-modal__panel',
|
||||
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'translate-prompt-generator-title' },
|
||||
},
|
||||
[refs.closeBtn, refs.title, refs.intro, layout]
|
||||
);
|
||||
|
||||
rootContainer.appendChild(refs.backdrop);
|
||||
rootContainer.appendChild(refs.panel);
|
||||
}
|
||||
|
||||
/** Berechnet den Prompt live neu. Ohne auswählbares Ausflugsziel wird stattdessen ein Hinweis angezeigt. */
|
||||
function updateOutput() {
|
||||
const { places } = EventMap.store.getState();
|
||||
const place = places.find((p) => p.id === refs.placeSelect.value);
|
||||
|
||||
if (!place) {
|
||||
refs.output.value = '(Bitte zuerst ein Ausflugsziel anlegen, um einen Übersetzungs-Prompt zu erzeugen.)';
|
||||
refs.copyBtn.disabled = true;
|
||||
refs.downloadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refs.output.value = EventMap.ai.buildTranslationPrompt(place, refs.languageSelect.value);
|
||||
refs.copyBtn.disabled = false;
|
||||
refs.downloadBtn.disabled = false;
|
||||
}
|
||||
|
||||
function resetCopyStatus() {
|
||||
refs.copyStatus.textContent = '';
|
||||
}
|
||||
|
||||
async function handleCopyClick() {
|
||||
const success = await EventMap.utils.copyToClipboard(refs.output.value);
|
||||
const defaultLabel = 'In Zwischenablage kopieren';
|
||||
|
||||
if (success) {
|
||||
refs.copyBtn.textContent = 'Kopiert!';
|
||||
refs.copyStatus.textContent = '';
|
||||
} else {
|
||||
refs.copyBtn.textContent = defaultLabel;
|
||||
refs.copyStatus.textContent =
|
||||
'Kopieren war nicht möglich. Bitte den Text im Feld oben manuell markieren und kopieren.';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
refs.copyBtn.textContent = defaultLabel;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function handleDownloadClick() {
|
||||
EventMap.utils.downloadTextFile('eventmap-uebersetzung-prompt.txt', refs.output.value);
|
||||
}
|
||||
|
||||
/** Baut die Ortsauswahl neu auf (z.B. falls seit dem letzten Öffnen Orte hinzugekommen sind), behält die Auswahl falls noch gültig. */
|
||||
function refreshPlaceOptions() {
|
||||
const currentValue = refs.placeSelect.value;
|
||||
clearChildren(refs.placeSelect);
|
||||
buildPlaceOptions().forEach((option) => refs.placeSelect.appendChild(option));
|
||||
if ([...refs.placeSelect.options].some((opt) => opt.value === currentValue)) {
|
||||
refs.placeSelect.value = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffnet den Übersetzungs-Prompt-Generator. */
|
||||
function openTranslatePromptGenerator() {
|
||||
if (!container || !refs) {
|
||||
console.error(
|
||||
'EventMap: Übersetzungs-Prompt-Generator wurde noch nicht initialisiert (initTranslatePromptGenerator()).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshPlaceOptions();
|
||||
resetCopyStatus();
|
||||
updateOutput();
|
||||
|
||||
container.hidden = false;
|
||||
document.body.classList.add('prompt-generator-modal-open');
|
||||
refs.placeSelect.focus();
|
||||
}
|
||||
|
||||
function closeTranslatePromptGenerator() {
|
||||
if (!container || container.hidden) return;
|
||||
container.hidden = true;
|
||||
document.body.classList.remove('prompt-generator-modal-open');
|
||||
}
|
||||
|
||||
/** Initialisiert das Übersetzungs-Prompt-Generator-Modal im Element mit der übergebenen id. */
|
||||
function initTranslatePromptGenerator(containerId) {
|
||||
const containerEl = document.getElementById(containerId);
|
||||
if (!containerEl) {
|
||||
console.error(`EventMap: Übersetzungs-Prompt-Generator-Container #${containerId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
container = containerEl;
|
||||
clearChildren(container);
|
||||
buildSkeleton(container);
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !container.hidden) {
|
||||
closeTranslatePromptGenerator();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EventMap.ui.initTranslatePromptGenerator = initTranslatePromptGenerator;
|
||||
EventMap.ui.openTranslatePromptGenerator = openTranslatePromptGenerator;
|
||||
})();
|
||||
@@ -18,6 +18,7 @@ window.EventMap.ui = window.EventMap.ui || {};
|
||||
window.EventMap.filters = window.EventMap.filters || {};
|
||||
window.EventMap.ai = window.EventMap.ai || {};
|
||||
window.EventMap.import = window.EventMap.import || {};
|
||||
window.EventMap.i18n = window.EventMap.i18n || {};
|
||||
|
||||
(function () {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user