Files
eventmap/js/ui/promptGenerator.js
T
dapelza 5e2197ec24 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.
2026-07-24 12:32:37 +02:00

443 lines
15 KiB
JavaScript
Raw 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 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;
})();