Files
eventmap/js/ui/promptGenerator.js
dapelza 39321d680f Phase 7: KI-JSON-Import und Backup-Export/Import
Neues Modul js/import/ mit reiner Validierungslogik
(importValidator.js: parseAndValidate(), findDuplicate()). Jeder
Ausflugsziel-Eintrag im importierten KI-JSON wird einzeln bewertet statt
alles-oder-nichts: fehlender Name/Kategorie oder ungueltige Koordinaten
fuehren zur Ablehnung, weiche Probleme (unbekannte Kategorie, unsichere
website/image-URL, ungueltige Bewertung, falscher Feldtyp) erzeugen nur
eine Warnung. sanitizeEntry() setzt sicherheitskritische Felder vor der
Uebernahme zurueck (z.B. javascript:-URLs), da normalizePlace() selbst
keine Sicherheitspruefung vornimmt. findDuplicate() erkennt
wahrscheinliche Duplikate gegen den bestehenden Datenbestand.

UI (js/ui/import.js): Modal mit zwei Tabs - KI-Ausflugsziele importieren
(Text einfuegen oder Datei hochladen, Vorschau mit
gueltig/Warnung/abgelehnt, Duplikat-Toggle, Bestaetigen) und Backup
sichern/wiederherstellen (js/import/backup.js: Export als JSON-Datei,
Wiederherstellen mit Ersetzen/Ergaenzen-Wahl). Vorschau-Rendering
ausschliesslich ueber el()/textContent, da es sich um echte, ungepruefte
Fremddaten handelt.

Damit sind alle Kernfunktionen aus anforderung.md umgesetzt.
docs/plan.md entsprechend fortgeschrieben.
2026-07-24 12:50:05 +02:00

444 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// KI-Prompt-Generator (Modal), Phase 6 (siehe anforderung.md Abschnitt
// "KI-Prompt-Generator"). Sammelt Formularwerte, übergibt sie an die reine
// Funktion EventMap.ai.buildPrompt() (js/ai/promptBuilder.js) und zeigt das
// Ergebnis live in einem <textarea readonly> an. Bietet zusätzlich Buttons
// zum Kopieren (Zwischenablage) und Speichern als Textdatei.
//
// WICHTIG: Diese Datei implementiert nur die Prompt-Erzeugung. Der JSON-Import
// (Einlesen der KI-Antwort zurück in die App) ist NICHT Teil dieser Datei,
// sondern liegt in js/ui/import.js (Phase 7, Button "📥 KI-Ausflugsziele
// importieren" in der Toolbar).
//
// Aufbau/Muster analog zu js/ui/form.js (Modal mit Backdrop, Escape schließt,
// Fokus auf erstes Feld beim Öffnen). Rendering ausschließlich über
// textContent/DOM-APIs (el()), niemals innerHTML mit Nutzerdaten.
(function () {
const { el, clearChildren } = EventMap.utils;
const SPECIAL_REQUIREMENTS = [
'Kinderwagen geeignet',
'Für Kleinkinder geeignet',
'Bei schlechtem Wetter geeignet',
'Kostenlos',
'Mit Tieren',
'Mit Spielplatz',
'Ruhige Umgebung',
'Barrierefrei',
'Für einen Tagesausflug geeignet',
];
const ENVIRONMENT_OPTIONS = [
{ value: 'both', label: 'Indoor & Outdoor' },
{ value: 'indoor', label: 'Nur Indoor' },
{ value: 'outdoor', label: 'Nur Outdoor' },
];
const COST_RANGE_OPTIONS = [
{ value: 'any', label: 'Beliebig (keine Einschränkung)' },
{ value: 'low', label: 'Kostenlos oder günstig' },
{ value: 'free', label: 'Nur kostenlos' },
];
const LANGUAGE_OPTIONS = [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'Englisch' },
];
let container = null;
let refs = null;
function section(legendText, children) {
return el('fieldset', { className: 'place-form__section' }, [
el('legend', { className: 'place-form__section-label', text: legendText }),
...children,
]);
}
function row(children) {
return el('div', { className: 'place-form__row' }, children);
}
function field(id, labelText, inputNode, { required = false } = {}) {
inputNode.id = id;
if (required) {
inputNode.setAttribute('required', '');
inputNode.setAttribute('aria-required', 'true');
}
const labelChildren = [labelText];
if (required) {
labelChildren.push(
el('span', { className: 'form-field__required', attrs: { 'aria-hidden': 'true' } }, [' *'])
);
}
return el('div', { className: 'form-field' }, [
el('label', { className: 'form-field__label', attrs: { for: id } }, labelChildren),
inputNode,
]);
}
function buildCategoryGroup() {
const { CATEGORIES } = EventMap.config;
refs.categoryInputs = [];
const options = CATEGORIES.map((cat) => {
const id = `pg-cat-${cat.id}`;
const input = el('input', {
attrs: { type: 'checkbox', id, value: cat.id },
on: { change: updateOutput },
});
refs.categoryInputs.push(input);
return el('label', { className: 'filter-panel__category-option', attrs: { for: id } }, [
input,
el('span', {
className: 'filter-panel__category-swatch',
attrs: { 'aria-hidden': 'true' },
style: { backgroundColor: cat.color },
}),
`${cat.icon} ${cat.label}`,
]);
});
return el('div', { className: 'filter-panel__categories' }, options);
}
function buildSpecialRequirementsGroup() {
refs.specialRequirementInputs = SPECIAL_REQUIREMENTS.map((label, index) => {
const id = `pg-requirement-${index}`;
const input = el('input', {
attrs: { type: 'checkbox', id, value: label },
on: { change: updateOutput },
});
return { input, label };
});
const options = refs.specialRequirementInputs.map(({ input, label }) =>
el('label', { className: 'form-checkbox', attrs: { for: input.id } }, [input, label])
);
return el('div', { className: 'place-form__checkbox-group' }, options);
}
// Die eigentliche id/for-Verdrahtung übernimmt field(); hier nur die
// <option>-Liste erzeugen.
function buildSelect(options) {
return el(
'select',
{ on: { change: updateOutput } },
options.map((opt) => el('option', { attrs: { value: opt.value }, text: opt.label }))
);
}
function buildSkeleton(rootContainer) {
refs = {};
refs.backdrop = el('div', {
className: 'prompt-generator-modal__backdrop',
on: { click: () => closePromptGenerator() },
});
refs.closeBtn = el(
'button',
{
className: 'prompt-generator-modal__close',
attrs: { type: 'button', 'aria-label': 'KI-Prompt-Generator schließen' },
on: { click: () => closePromptGenerator() },
},
['✕']
);
refs.title = el('h2', {
className: 'prompt-generator__title',
attrs: { id: 'prompt-generator-title' },
text: '🤖 Neue Ausflugsziele mit KI erstellen',
});
refs.intro = el('p', {
className: 'prompt-generator__intro',
text:
'Fülle das Formular aus, um automatisch einen fertigen Prompt für eine externe KI ' +
'(z.B. ChatGPT, Claude, Gemini) zu erzeugen. Der Prompt bittet die KI, passende ' +
'Ausflugsziele zu recherchieren und als strukturiertes JSON zurückzugeben.',
});
// -- Eingabefelder --
refs.region = el('input', {
attrs: { type: 'text', placeholder: 'z.B. München, Chiemgau, Bayern …' },
on: { input: updateOutput },
});
refs.locationHint = el('p', { className: 'filter-panel__hint' });
refs.locationHint.hidden = true;
refs.useLocationBtn = el(
'button',
{
className: 'btn btn--secondary prompt-generator__location-btn',
attrs: { type: 'button' },
on: { click: handleUseLocationClick },
},
['Standort als Suchgebiet übernehmen']
);
refs.useLocationBtn.hidden = true;
refs.radius = el('input', {
attrs: { type: 'number', min: '1', step: '1', placeholder: 'z.B. 30' },
on: { input: updateOutput },
});
refs.ageMin = el('input', {
attrs: { type: 'number', min: '0', step: '1', placeholder: 'ab' },
on: { input: updateOutput },
});
refs.ageMax = el('input', {
attrs: { type: 'number', min: '0', step: '1', placeholder: 'bis' },
on: { input: updateOutput },
});
refs.environment = buildSelect(ENVIRONMENT_OPTIONS);
refs.costRange = buildSelect(COST_RANGE_OPTIONS);
refs.costDetails = el('input', {
attrs: { type: 'text', placeholder: 'z.B. maximal 15 € Eintritt pro Familie (optional)' },
on: { input: updateOutput },
});
refs.maxResults = el('input', {
attrs: { type: 'number', min: '1', step: '1', value: '10' },
on: { input: updateOutput },
});
refs.additionalRequirements = el('textarea', {
attrs: { placeholder: 'Weitere, oben nicht aufgeführte Anforderungen (optional)' },
on: { input: updateOutput },
});
refs.outputLanguage = buildSelect(LANGUAGE_OPTIONS);
const form = el('div', { className: 'place-form prompt-generator__form' }, [
section('Suchgebiet', [
field('pg-region', 'Region, Ort oder Suchgebiet', refs.region, { required: true }),
refs.locationHint,
refs.useLocationBtn,
field('pg-radius', 'Maximaler Radius (km)', refs.radius),
]),
section('Kategorien', [buildCategoryGroup()]),
section('Altersgruppe der Kinder', [
row([
field('pg-age-min', 'Alter ab (Jahre)', refs.ageMin),
field('pg-age-max', 'Alter bis (Jahre)', refs.ageMax),
]),
]),
section('Umgebung & Kosten', [
field('pg-environment-field', 'Indoor, Outdoor oder beides', refs.environment),
field('pg-cost-range-field', 'Kostenrahmen', refs.costRange),
field('pg-cost-details-field', 'Kostenrahmen zusätzliche Angabe', refs.costDetails),
]),
section('Ergebnisse', [field('pg-max-results-field', 'Maximale Anzahl an Ergebnissen', refs.maxResults)]),
section('Besondere Anforderungen', [
buildSpecialRequirementsGroup(),
field('pg-additional-requirements-field', 'Zusätzliche Anforderungen (Freitext)', refs.additionalRequirements),
]),
section('Sprache der Ausgabe', [
field('pg-output-language-field', 'Sprache der KI-Ergebnistexte', refs.outputLanguage),
]),
]);
// -- Ausgabebereich --
refs.output = el('textarea', {
className: 'prompt-generator__output-textarea',
attrs: { readonly: '', id: '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: 'Formular ausfüllen.' }),
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 der KI kopieren und über den Button "📥 KI-Ausflugsziele importieren" ' +
'(direkt neben diesem Button) prüfen und importieren.',
}),
]),
]),
]);
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': 'prompt-generator-title' },
},
[refs.closeBtn, refs.title, refs.intro, layout]
);
rootContainer.appendChild(refs.backdrop);
rootContainer.appendChild(refs.panel);
}
function readCheckedValues(inputs) {
return inputs.filter((input) => input.checked).map((input) => input.value);
}
function parseOptionalNumber(raw) {
const trimmed = (raw || '').trim();
if (trimmed === '') return null;
const num = Number(trimmed);
return Number.isFinite(num) ? num : null;
}
function readFormValues() {
const { CATEGORIES } = EventMap.config;
const selectedCategoryIds = readCheckedValues(refs.categoryInputs);
const categoryLabels = CATEGORIES.filter((cat) => selectedCategoryIds.includes(cat.id)).map(
(cat) => cat.label
);
return {
region: refs.region.value,
radiusKm: parseOptionalNumber(refs.radius.value),
categoryLabels,
ageMin: parseOptionalNumber(refs.ageMin.value),
ageMax: parseOptionalNumber(refs.ageMax.value),
environment: refs.environment.value,
costRange: refs.costRange.value,
costDetails: refs.costDetails.value,
maxResults: parseOptionalNumber(refs.maxResults.value) || 10,
specialRequirements: readCheckedValues(refs.specialRequirementInputs.map((r) => r.input)),
additionalRequirements: refs.additionalRequirements.value,
outputLanguage: refs.outputLanguage.value,
};
}
/** Berechnet den Prompt live neu und zeigt ihn im Ausgabefeld an. Ohne gültige Region wird stattdessen ein Hinweis angezeigt. */
function updateOutput() {
const values = readFormValues();
const hasRegion = (values.region || '').trim() !== '';
if (!hasRegion) {
refs.output.value = '(Bitte zunächst eine Region, einen Ort oder ein Suchgebiet angeben, um den Prompt zu erzeugen.)';
refs.copyBtn.disabled = true;
refs.downloadBtn.disabled = true;
return;
}
refs.output.value = EventMap.ai.buildPrompt(values);
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-ki-prompt.txt', refs.output.value);
}
function handleUseLocationClick() {
const { userLocation } = EventMap.store.getState();
if (!userLocation) return;
refs.region.value = `Umkreis um ${userLocation.lat.toFixed(4)}, ${userLocation.lng.toFixed(4)}`;
updateOutput();
refs.region.focus();
}
function syncLocationHint() {
const { userLocation } = EventMap.store.getState();
const available = Boolean(userLocation);
refs.locationHint.hidden = !available;
refs.useLocationBtn.hidden = !available;
if (available) {
refs.locationHint.textContent = `Dein aktueller Standort ist bekannt (${userLocation.lat.toFixed(4)}, ${userLocation.lng.toFixed(4)}).`;
}
}
/** Öffnet den KI-Prompt-Generator. */
function openPromptGenerator() {
if (!container || !refs) {
console.error('EventMap: Prompt-Generator wurde noch nicht initialisiert (initPromptGenerator()).');
return;
}
syncLocationHint();
resetCopyStatus();
updateOutput();
container.hidden = false;
document.body.classList.add('prompt-generator-modal-open');
refs.region.focus();
}
function closePromptGenerator() {
if (!container || container.hidden) return;
container.hidden = true;
document.body.classList.remove('prompt-generator-modal-open');
}
/** Initialisiert das Prompt-Generator-Modal im Element mit der übergebenen id. */
function initPromptGenerator(containerId) {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
console.error(`EventMap: Prompt-Generator-Container #${containerId} nicht gefunden.`);
return;
}
container = containerEl;
clearChildren(container);
buildSkeleton(container);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !container.hidden) {
closePromptGenerator();
}
});
}
EventMap.ui.initPromptGenerator = initPromptGenerator;
EventMap.ui.openPromptGenerator = openPromptGenerator;
})();