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.
444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
// 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;
|
||
})();
|