Phase 6: KI-Prompt-Generator

Neues Modul js/ai/promptBuilder.js: reine Funktion buildPrompt(), deckt
alle 10 Pflichtpunkte aus anforderung.md explizit ab (Region+Radius,
Kategorien, Alter, wichtige Eigenschaften, Anzahl Ergebnisse, keine
erfundenen Informationen, unsichere Angaben kennzeichnen, ausschliesslich
gueltiges JSON, genaue Koordinaten, direkte Importierbarkeit) und bettet
das exakte JSON-Zielschema aus anforderung.md unveraendert ein.

UI (js/ui/promptGenerator.js): Modal mit allen Eingabefeldern aus der
Anforderung (Region, Radius, Kategorien, Altersgruppe, Indoor/Outdoor,
Kostenrahmen, max. Ergebnisse, besondere Anforderungen als Checkboxen +
Freitext, Ausgabesprache), Live-Vorschau des generierten Prompts,
Copy-to-Clipboard mit execCommand-Fallback (js/utils/clipboard.js) und
Speichern als Textdatei (js/utils/download.js). Neuer Toolbar-Button
oeffnet den Generator.

JSON-Import der KI-Antwort ist bewusst nicht Teil dieser Phase, sondern
folgt in Phase 7. docs/plan.md entsprechend fortgeschrieben.
This commit is contained in:
2026-07-24 12:32:37 +02:00
parent dac952fbf9
commit 5e2197ec24
9 changed files with 963 additions and 2 deletions
+442
View File
@@ -0,0 +1,442 @@
// 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 Phase implementiert nur die Prompt-Erzeugung. Der spätere
// JSON-Import (Einlesen der KI-Antwort zurück in die App) ist NICHT Teil
// dieser Datei - das folgt in Phase 7.
//
// 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 im nächsten Schritt (KI-JSON-Import, in einer späteren Version ' +
'dieser Anwendung) 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;
})();