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:
@@ -0,0 +1,237 @@
|
||||
// Reine Funktion zur Erzeugung des KI-Prompt-Textes für den
|
||||
// KI-Prompt-Generator (Phase 6, siehe anforderung.md Abschnitt
|
||||
// "KI-Prompt-Generator"). Keine UI-, keine Store-Zugriffe: nimmt
|
||||
// ausschließlich Formularwerte entgegen und liefert einen fertigen
|
||||
// Prompt-String zurück (js/ui/promptGenerator.js ruft diese Funktion bei
|
||||
// jeder Formularänderung neu auf).
|
||||
//
|
||||
// Design-Entscheidung Sprache: Der Anweisungstext des Prompts selbst wird
|
||||
// immer auf Deutsch verfasst (Zielgruppe der App ist deutschsprachig, siehe
|
||||
// restliche UI-Texte). Das Formularfeld "Sprache der Ausgabe" steuert
|
||||
// ausschließlich, in welcher Sprache die KI ihre Ergebnistexte
|
||||
// (name/shortDescription/description) verfassen soll - das wird im Prompt
|
||||
// explizit als eigene Anweisung an die KI übergeben.
|
||||
|
||||
(function () {
|
||||
// 1:1 aus anforderung.md, Abschnitt "Vorgabe für das KI-JSON" übernommen,
|
||||
// damit die KI exakt das Zielschema vor Augen hat, das später (Phase 7)
|
||||
// in die Anwendung importiert werden kann.
|
||||
const JSON_TEMPLATE = `{
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-01-01T12:00:00Z",
|
||||
"region": "Beispielregion",
|
||||
"places": [
|
||||
{
|
||||
"name": "Beispiel-Ausflugsziel",
|
||||
"category": "Spielplatz",
|
||||
"shortDescription": "Kurze Beschreibung des Ausflugsziels.",
|
||||
"description": "Ausführliche Beschreibung des Ausflugsziels und seiner Besonderheiten.",
|
||||
"latitude": 52.520008,
|
||||
"longitude": 13.404954,
|
||||
"address": "Beispielstraße 1",
|
||||
"city": "Beispielstadt",
|
||||
"region": "Beispielregion",
|
||||
"ageRecommendation": {
|
||||
"min": 2,
|
||||
"max": 12
|
||||
},
|
||||
"cost": {
|
||||
"type": "free",
|
||||
"description": "Kostenlos"
|
||||
},
|
||||
"openingHours": "Täglich von 08:00 bis 20:00 Uhr",
|
||||
"durationMinutes": 120,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": false,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": false
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": null,
|
||||
"tags": [
|
||||
"familienfreundlich",
|
||||
"kostenlos",
|
||||
"Spielplatz"
|
||||
],
|
||||
"source": "",
|
||||
"sourceVerified": false
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const ENVIRONMENT_LABELS = {
|
||||
indoor: 'ausschließlich Indoor-Ziele (drinnen)',
|
||||
outdoor: 'ausschließlich Outdoor-Ziele (draußen)',
|
||||
both: 'sowohl Indoor- als auch Outdoor-Ziele',
|
||||
};
|
||||
|
||||
const COST_RANGE_LABELS = {
|
||||
any: 'Der Kostenrahmen spielt keine Rolle.',
|
||||
free: 'Bitte ausschließlich kostenlose Ausflugsziele vorschlagen.',
|
||||
low: 'Bitte bevorzugt kostenlose oder preisgünstige Ausflugsziele (z.B. geringer Eintrittspreis) vorschlagen.',
|
||||
};
|
||||
|
||||
const LANGUAGE_LABELS = {
|
||||
de: 'Deutsch',
|
||||
en: 'Englisch (English)',
|
||||
};
|
||||
|
||||
function formatRegionAndRadius(region, radiusKm) {
|
||||
const trimmedRegion = (region || '').trim() || '(keine Region angegeben)';
|
||||
if (radiusKm !== null && radiusKm !== undefined && Number.isFinite(radiusKm) && radiusKm > 0) {
|
||||
return (
|
||||
`Recherchiere ausschließlich familienfreundliche Ausflugsziele im folgenden Gebiet: ` +
|
||||
`"${trimmedRegion}", innerhalb eines Umkreises von maximal ${radiusKm} km um dieses Gebiet.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`Recherchiere ausschließlich familienfreundliche Ausflugsziele im folgenden Gebiet: ` +
|
||||
`"${trimmedRegion}". Es ist kein fester Radius vorgegeben - bleibe sinnvoll im Umfeld dieser Region.`
|
||||
);
|
||||
}
|
||||
|
||||
function formatCategories(categoryLabels) {
|
||||
const { CATEGORIES } = EventMap.config;
|
||||
const allLabels = CATEGORIES.map((cat) => cat.label);
|
||||
const validValuesHint = `Verwende im JSON-Feld "category" ausschließlich einen der folgenden Werte: ${allLabels.join(', ')}.`;
|
||||
|
||||
if (Array.isArray(categoryLabels) && categoryLabels.length > 0) {
|
||||
return `Berücksichtige ausschließlich Ausflugsziele der folgenden Kategorien: ${categoryLabels.join(', ')}. ${validValuesHint}`;
|
||||
}
|
||||
return `Es ist keine Kategorie eingeschränkt - alle familienfreundlichen Kategorien aus der folgenden Liste sind willkommen. ${validValuesHint}`;
|
||||
}
|
||||
|
||||
function formatAgeRange(ageMin, ageMax) {
|
||||
const hasMin = ageMin !== null && ageMin !== undefined && Number.isFinite(ageMin);
|
||||
const hasMax = ageMax !== null && ageMax !== undefined && Number.isFinite(ageMax);
|
||||
|
||||
if (hasMin && hasMax) {
|
||||
return `Die Ausflugsziele sollen für Kinder im Alter von ${ageMin} bis ${ageMax} Jahren geeignet sein.`;
|
||||
}
|
||||
if (hasMin) {
|
||||
return `Die Ausflugsziele sollen für Kinder ab ${ageMin} Jahren geeignet sein.`;
|
||||
}
|
||||
if (hasMax) {
|
||||
return `Die Ausflugsziele sollen für Kinder bis ${ageMax} Jahren geeignet sein.`;
|
||||
}
|
||||
return 'Es ist keine bestimmte Altersgruppe vorgegeben - die Ausflugsziele sollen grundsätzlich für Familien mit Kindern geeignet sein.';
|
||||
}
|
||||
|
||||
function formatFeatures(specialRequirements, additionalRequirements, environmentKey, costRangeKey, costDetails) {
|
||||
const lines = [];
|
||||
|
||||
lines.push(`Umgebung: ${ENVIRONMENT_LABELS[environmentKey] || ENVIRONMENT_LABELS.both}.`);
|
||||
lines.push(COST_RANGE_LABELS[costRangeKey] || COST_RANGE_LABELS.any);
|
||||
if ((costDetails || '').trim() !== '') {
|
||||
lines.push(`Zusätzliche Angabe zum Kostenrahmen: ${costDetails.trim()}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(specialRequirements) && specialRequirements.length > 0) {
|
||||
lines.push(
|
||||
`Folgende Eigenschaften sind besonders wichtig: ${specialRequirements.join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
if ((additionalRequirements || '').trim() !== '') {
|
||||
lines.push(`Weitere Anforderungen: ${additionalRequirements.trim()}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatMaxResults(maxResults) {
|
||||
const n = Number.isFinite(maxResults) && maxResults > 0 ? Math.round(maxResults) : 10;
|
||||
return (
|
||||
`Liefere maximal ${n} Ausflugsziele. Liefere lieber weniger, aber dafür ausschließlich ` +
|
||||
`korrekte und gut recherchierte Einträge, statt die Zielzahl mit unsicheren oder erfundenen Orten aufzufüllen.`
|
||||
);
|
||||
}
|
||||
|
||||
function formatLanguage(outputLanguage) {
|
||||
const label = LANGUAGE_LABELS[outputLanguage] || LANGUAGE_LABELS.de;
|
||||
return `Verfasse die Textfelder "name", "shortDescription" und "description" in folgender Sprache: ${label}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus den Formularwerten des Prompt-Generators einen vollständigen,
|
||||
* kopierbaren Prompt-Text.
|
||||
*
|
||||
* @param {object} values
|
||||
* @param {string} values.region Region/Ort/Suchgebiet (Pflichtfeld)
|
||||
* @param {number|null} values.radiusKm Maximaler Radius in km
|
||||
* @param {string[]} values.categoryLabels Gewünschte Kategorien (Klartext-Labels)
|
||||
* @param {number|null} values.ageMin Altersgruppe ab
|
||||
* @param {number|null} values.ageMax Altersgruppe bis
|
||||
* @param {'indoor'|'outdoor'|'both'} values.environment
|
||||
* @param {'any'|'free'|'low'} values.costRange
|
||||
* @param {string} values.costDetails Freitext-Zusatz zum Kostenrahmen
|
||||
* @param {number} values.maxResults Maximale Anzahl an Ergebnissen
|
||||
* @param {string[]} values.specialRequirements Ausgewählte besondere Anforderungen
|
||||
* @param {string} values.additionalRequirements Freitext für weitere Anforderungen
|
||||
* @param {'de'|'en'} values.outputLanguage Sprache der KI-Ausgabetexte
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildPrompt(values) {
|
||||
const v = values || {};
|
||||
|
||||
const sections = [
|
||||
'Du bist ein sorgfältiger Recherche-Assistent für familienfreundliche Ausflugsziele mit Kindern.',
|
||||
'Recherchiere passende Ausflugsziele anhand der folgenden Vorgaben und liefere das Ergebnis ' +
|
||||
'ausschließlich im weiter unten vorgegebenen JSON-Format.',
|
||||
'',
|
||||
'1. Region und Suchradius',
|
||||
formatRegionAndRadius(v.region, v.radiusKm),
|
||||
'',
|
||||
'2. Gewünschte Kategorien',
|
||||
formatCategories(v.categoryLabels),
|
||||
'',
|
||||
'3. Altersgruppe der Kinder',
|
||||
formatAgeRange(v.ageMin, v.ageMax),
|
||||
'',
|
||||
'4. Wichtige Eigenschaften und besondere Anforderungen',
|
||||
formatFeatures(v.specialRequirements, v.additionalRequirements, v.environment, v.costRange, v.costDetails),
|
||||
'',
|
||||
'5. Anzahl der Ergebnisse',
|
||||
formatMaxResults(v.maxResults),
|
||||
'',
|
||||
'6. Keine erfundenen Informationen',
|
||||
'Gib ausschließlich Ausflugsziele an, die dir tatsächlich bekannt sind bzw. die du recherchieren ' +
|
||||
'kannst. Erfinde unter keinen Umständen Orte, Adressen, Koordinaten oder sonstige Angaben.',
|
||||
'',
|
||||
'7. Unsichere Angaben kennzeichnen',
|
||||
'Falls du dir bei einzelnen Angaben (z.B. genauen Öffnungszeiten, Koordinaten oder Kosten) nicht ' +
|
||||
'vollständig sicher bist, kennzeichne dies deutlich: setze in diesem Fall das Feld "sourceVerified" ' +
|
||||
'auf false und ergänze im Feld "description" einen kurzen Hinweis wie "Angabe ungeprüft".',
|
||||
'',
|
||||
'8. 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.',
|
||||
'',
|
||||
'9. Genaue Koordinaten',
|
||||
'Liefere für jedes Ausflugsziel möglichst genaue Koordinaten (Felder "latitude"/"longitude" als ' +
|
||||
'Dezimalgrad mit mindestens 5 Nachkommastellen), damit der Ort exakt auf einer Karte lokalisiert werden kann.',
|
||||
'',
|
||||
'10. Direkte Importierbarkeit',
|
||||
'Das JSON muss exakt dem unten stehenden Zielformat entsprechen (identische Feldnamen, Struktur ' +
|
||||
'und Datentypen), damit es ohne manuelle Anpassung direkt in die Anwendung importiert werden kann.',
|
||||
'',
|
||||
'Sprache der Ausgabetexte',
|
||||
formatLanguage(v.outputLanguage),
|
||||
'',
|
||||
'Zielformat (bitte exakt einhalten, Beispielwerte durch recherchierte Daten ersetzen)',
|
||||
'```json',
|
||||
JSON_TEMPLATE,
|
||||
'```',
|
||||
];
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
EventMap.ai.buildPrompt = buildPrompt;
|
||||
EventMap.ai.JSON_TEMPLATE = JSON_TEMPLATE;
|
||||
})();
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
(function () {
|
||||
const { initStore } = EventMap.store;
|
||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||
const { initList, initDetail, initForm, initFilterPanel } = EventMap.ui;
|
||||
const { initList, initDetail, initForm, initFilterPanel, initPromptGenerator } = EventMap.ui;
|
||||
|
||||
function showFatalError(message) {
|
||||
const status = document.getElementById('app-status');
|
||||
@@ -48,6 +48,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupPromptGeneratorButton() {
|
||||
const button = document.getElementById('open-prompt-generator-btn');
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
EventMap.ui.openPromptGenerator();
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await initStore();
|
||||
initMap('map');
|
||||
@@ -55,8 +64,10 @@
|
||||
initList('place-list');
|
||||
initDetail('detail-modal');
|
||||
initForm('place-form-modal');
|
||||
initPromptGenerator('prompt-generator-modal');
|
||||
setupGeolocationButton();
|
||||
setupAddPlaceButton();
|
||||
setupPromptGeneratorButton();
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
// Zwischenablage-Utility für den KI-Prompt-Generator (Phase 6). Nutzt primär
|
||||
// die moderne Clipboard API, fällt aber auf das ältere
|
||||
// document.execCommand('copy') zurück, falls navigator.clipboard nicht
|
||||
// verfügbar ist oder der Aufruf fehlschlägt (z.B. weil manche Browser die
|
||||
// Clipboard API unter file:// einschränken).
|
||||
|
||||
(function () {
|
||||
/** Kopiert per unsichtbarem <textarea> + execCommand('copy'). Gibt boolean zurück. */
|
||||
function copyViaExecCommand(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
// Unsichtbar, aber im Layout-Fluss vorhanden, damit select() zuverlässig funktioniert.
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
} catch (err) {
|
||||
success = false;
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert einen Text in die Zwischenablage.
|
||||
* @param {string} text
|
||||
* @returns {Promise<boolean>} true, wenn das Kopieren erfolgreich war.
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Fällt unten auf den execCommand-Fallback zurück (z.B. Berechtigung
|
||||
// verweigert oder API unter file:// eingeschränkt).
|
||||
}
|
||||
}
|
||||
|
||||
return copyViaExecCommand(text);
|
||||
}
|
||||
|
||||
EventMap.utils.copyToClipboard = copyToClipboard;
|
||||
})();
|
||||
@@ -16,6 +16,7 @@ window.EventMap.store = window.EventMap.store || {};
|
||||
window.EventMap.map = window.EventMap.map || {};
|
||||
window.EventMap.ui = window.EventMap.ui || {};
|
||||
window.EventMap.filters = window.EventMap.filters || {};
|
||||
window.EventMap.ai = window.EventMap.ai || {};
|
||||
|
||||
(function () {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Download-Utility für den KI-Prompt-Generator (Phase 6): erzeugt clientseitig
|
||||
// einen Blob und triggert den Download über ein unsichtbares
|
||||
// <a download>-Element. Funktioniert rein clientseitig, also auch unter
|
||||
// file:// ohne lokalen Server.
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Speichert Text als Datei über den Browser-Download.
|
||||
* @param {string} filename
|
||||
* @param {string} content
|
||||
*/
|
||||
function downloadTextFile(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// URL erst leicht verzögert freigeben, damit der Download-Vorgang
|
||||
// in jedem Browser sicher gestartet werden konnte.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
EventMap.utils.downloadTextFile = downloadTextFile;
|
||||
})();
|
||||
Reference in New Issue
Block a user