// Formular (Modal) zum manuellen Anlegen und Bearbeiten eines // Ausflugsziels (Phase 3, siehe anforderung.md Abschnitt 6). // // API: EventMap.ui.initForm(containerId) initialisiert das Modal einmalig // (analog zu js/ui/detail.js). EventMap.ui.openPlaceForm(placeOrNull) öffnet // es: ohne Argument im "Neu anlegen"-Modus, mit einem Place-Objekt im // "Bearbeiten"-Modus (überschreibt den bestehenden Eintrag statt einen // neuen anzulegen). // // Rendering ausschließlich über textContent/DOM-APIs (el()), niemals // innerHTML mit Formular-/Place-Daten (siehe js/utils/dom.js). (function () { const { getState, setState, persistPlaces } = EventMap.store; const { normalizePlace } = EventMap.models; const { COST_TYPES, ENVIRONMENT_TYPES } = EventMap.models; const { CATEGORIES } = EventMap.config; const { el, isSafeHttpUrl, isValidCoordinate } = EventMap.utils; const COST_LABELS = { free: 'Kostenlos', paid: 'Kostenpflichtig', mixed: 'Teilweise kostenpflichtig', unknown: 'Keine Angabe', }; const ENVIRONMENT_LABELS = { indoor: 'Drinnen (Indoor)', outdoor: 'Draußen (Outdoor)', mixed: 'Drinnen & draußen', }; // Felder, für die ein eigenes Fehler-Element existiert (siehe fieldWrapper()). const ERROR_FIELDS = [ 'name', 'category', 'latitude', 'longitude', 'ageMin', 'ageMax', 'duration', 'image', 'website', ]; let container = null; let refs = null; let editingPlace = null; function fieldWrapper(id, labelText, inputNode, { required = false } = {}) { inputNode.id = id; if (required) { inputNode.setAttribute('required', ''); inputNode.setAttribute('aria-required', 'true'); } const errorEl = el('p', { className: 'form-field__error', attrs: { id: `${id}-error`, role: 'alert' }, }); errorEl.hidden = true; inputNode.setAttribute('aria-describedby', `${id}-error`); const labelChildren = [labelText]; if (required) { labelChildren.push( el('span', { className: 'form-field__required', attrs: { 'aria-hidden': 'true' } }, [' *']) ); } const wrapper = el('div', { className: 'form-field' }, [ el('label', { className: 'form-field__label', attrs: { for: id } }, labelChildren), inputNode, errorEl, ]); return { wrapper, input: inputNode, errorEl }; } function textInput(type = 'text', extraAttrs = {}) { return el('input', { attrs: { type, ...extraAttrs } }); } function buildCategorySelect() { const select = el('select', {}, [ el('option', { attrs: { value: '' }, text: '-- Bitte wählen --' }), ...CATEGORIES.map((cat) => el('option', { attrs: { value: cat.id }, text: `${cat.icon} ${cat.label}` }) ), ]); return select; } function buildCostTypeSelect() { return el( 'select', {}, COST_TYPES.map((type) => el('option', { attrs: { value: type }, text: COST_LABELS[type] || type }) ) ); } function buildEnvironmentSelect() { return el( 'select', {}, ENVIRONMENT_TYPES.map((type) => el('option', { attrs: { value: type }, text: ENVIRONMENT_LABELS[type] || type }) ) ); } function checkboxField(id, labelText) { const input = el('input', { attrs: { type: 'checkbox', id } }); const wrapper = el('label', { className: 'form-checkbox', attrs: { for: id } }, [ input, labelText, ]); return { wrapper, input }; } 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 buildSkeleton(rootContainer) { const nodeRefs = {}; nodeRefs.backdrop = el('div', { className: 'place-form-modal__backdrop', on: { click: () => closeForm() }, }); nodeRefs.closeBtn = el( 'button', { className: 'place-form-modal__close', attrs: { type: 'button', 'aria-label': 'Formular schließen' }, on: { click: () => closeForm() }, }, ['✕'] ); nodeRefs.title = el('h2', { className: 'place-form__title', attrs: { id: 'place-form-title' }, }); // Basisdaten const nameField = fieldWrapper('pf-name', 'Name', textInput('text'), { required: true }); const categoryField = fieldWrapper('pf-category', 'Kategorie', buildCategorySelect(), { required: true, }); const shortDescriptionField = fieldWrapper( 'pf-short-description', 'Kurzbeschreibung', textInput('text') ); const descriptionField = fieldWrapper( 'pf-description', 'Ausführliche Beschreibung', el('textarea', {}) ); // Adresse / Position const addressField = fieldWrapper('pf-address', 'Adresse', textInput('text')); const cityField = fieldWrapper('pf-city', 'Ort', textInput('text')); const latitudeField = fieldWrapper( 'pf-latitude', 'Breitengrad', textInput('number', { step: 'any' }), { required: true } ); const longitudeField = fieldWrapper( 'pf-longitude', 'Längengrad', textInput('number', { step: 'any' }), { required: true } ); // Altersgruppe / Kosten / Öffnungszeiten / Dauer const ageMinField = fieldWrapper( 'pf-age-min', 'Altersgruppe – ab (Jahre)', textInput('number', { step: '1', min: '0' }) ); const ageMaxField = fieldWrapper( 'pf-age-max', 'Altersgruppe – bis (Jahre)', textInput('number', { step: '1', min: '0' }) ); const costTypeField = fieldWrapper('pf-cost-type', 'Kosten', buildCostTypeSelect()); const costDescriptionField = fieldWrapper( 'pf-cost-description', 'Kosten – Details', textInput('text') ); const openingHoursField = fieldWrapper('pf-opening-hours', 'Öffnungszeiten', textInput('text')); const durationField = fieldWrapper( 'pf-duration', 'Aufenthaltsdauer (Minuten)', textInput('number', { step: '1', min: '0' }) ); // Ausstattung const strollerCheckbox = checkboxField('pf-facility-stroller', 'Kinderwagen geeignet'); const wheelchairCheckbox = checkboxField('pf-facility-wheelchair', 'Barrierefrei'); const toiletsCheckbox = checkboxField('pf-facility-toilets', 'Toiletten vorhanden'); const parkingCheckbox = checkboxField('pf-facility-parking', 'Parkplatz vorhanden'); const restaurantCheckbox = checkboxField('pf-facility-restaurant', 'Gastronomie vorhanden'); const environmentField = fieldWrapper('pf-environment', 'Indoor / Outdoor', buildEnvironmentSelect()); // Weitere Informationen const imageField = fieldWrapper('pf-image', 'Bild-URL', textInput('url')); const websiteField = fieldWrapper('pf-website', 'Website', textInput('url')); const tagsField = fieldWrapper( 'pf-tags', 'Tags (kommagetrennt)', textInput('text') ); nodeRefs.cancelBtn = el( 'button', { className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: () => closeForm() } }, ['Abbrechen'] ); nodeRefs.submitBtn = el( 'button', { className: 'btn btn--primary', attrs: { type: 'submit' } }, ['Ausflugsziel speichern'] ); nodeRefs.form = el( 'form', { className: 'place-form', attrs: { id: 'place-form', novalidate: '' }, on: { submit: handleSubmit } }, [ section('Basisdaten', [ nameField.wrapper, categoryField.wrapper, shortDescriptionField.wrapper, descriptionField.wrapper, ]), section('Adresse & Position', [ addressField.wrapper, cityField.wrapper, row([latitudeField.wrapper, longitudeField.wrapper]), ]), section('Altersgruppe, Kosten & Zeiten', [ row([ageMinField.wrapper, ageMaxField.wrapper]), row([costTypeField.wrapper, costDescriptionField.wrapper]), openingHoursField.wrapper, durationField.wrapper, ]), section('Ausstattung', [ el('div', { className: 'place-form__checkbox-group' }, [ strollerCheckbox.wrapper, wheelchairCheckbox.wrapper, toiletsCheckbox.wrapper, parkingCheckbox.wrapper, restaurantCheckbox.wrapper, ]), environmentField.wrapper, ]), section('Weitere Informationen', [ imageField.wrapper, websiteField.wrapper, tagsField.wrapper, ]), el('div', { className: 'place-form__actions' }, [nodeRefs.cancelBtn, nodeRefs.submitBtn]), ] ); nodeRefs.panel = el( 'div', { className: 'place-form-modal__panel', attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'place-form-title' }, }, [nodeRefs.closeBtn, nodeRefs.title, nodeRefs.form] ); rootContainer.appendChild(nodeRefs.backdrop); rootContainer.appendChild(nodeRefs.panel); // Flache Referenzen für Lesen/Schreiben/Fehleranzeige der Feldwerte. nodeRefs.name = nameField.input; nodeRefs.nameError = nameField.errorEl; nodeRefs.category = categoryField.input; nodeRefs.categoryError = categoryField.errorEl; nodeRefs.shortDescription = shortDescriptionField.input; nodeRefs.description = descriptionField.input; nodeRefs.address = addressField.input; nodeRefs.city = cityField.input; nodeRefs.latitude = latitudeField.input; nodeRefs.latitudeError = latitudeField.errorEl; nodeRefs.longitude = longitudeField.input; nodeRefs.longitudeError = longitudeField.errorEl; nodeRefs.ageMin = ageMinField.input; nodeRefs.ageMinError = ageMinField.errorEl; nodeRefs.ageMax = ageMaxField.input; nodeRefs.ageMaxError = ageMaxField.errorEl; nodeRefs.costType = costTypeField.input; nodeRefs.costDescription = costDescriptionField.input; nodeRefs.openingHours = openingHoursField.input; nodeRefs.duration = durationField.input; nodeRefs.durationError = durationField.errorEl; nodeRefs.stroller = strollerCheckbox.input; nodeRefs.wheelchair = wheelchairCheckbox.input; nodeRefs.toilets = toiletsCheckbox.input; nodeRefs.parking = parkingCheckbox.input; nodeRefs.restaurant = restaurantCheckbox.input; nodeRefs.environment = environmentField.input; nodeRefs.image = imageField.input; nodeRefs.imageError = imageField.errorEl; nodeRefs.website = websiteField.input; nodeRefs.websiteError = websiteField.errorEl; nodeRefs.tags = tagsField.input; return nodeRefs; } function parseOptionalNumber(raw) { const trimmed = (raw || '').trim(); if (trimmed === '') return null; const num = Number(trimmed); return Number.isFinite(num) ? num : NaN; } function readValues() { return { name: refs.name.value.trim(), category: refs.category.value, shortDescription: refs.shortDescription.value.trim(), description: refs.description.value.trim(), address: refs.address.value.trim(), city: refs.city.value.trim(), latitude: parseOptionalNumber(refs.latitude.value), longitude: parseOptionalNumber(refs.longitude.value), ageMin: parseOptionalNumber(refs.ageMin.value), ageMax: parseOptionalNumber(refs.ageMax.value), costType: refs.costType.value, costDescription: refs.costDescription.value.trim(), openingHours: refs.openingHours.value.trim(), duration: parseOptionalNumber(refs.duration.value), stroller: refs.stroller.checked, wheelchair: refs.wheelchair.checked, toilets: refs.toilets.checked, parking: refs.parking.checked, restaurant: refs.restaurant.checked, environment: refs.environment.value, image: refs.image.value.trim(), website: refs.website.value.trim(), tags: refs.tags.value, }; } /** Validiert die gelesenen Formularwerte. Gibt ein Objekt {feldname: Fehlermeldung} zurück. */ function validate(values) { const errors = {}; if (!values.name) { errors.name = 'Bitte einen Namen angeben.'; } if (!values.category) { errors.category = 'Bitte eine Kategorie auswählen.'; } if (values.latitude === null) { errors.latitude = 'Bitte einen Breitengrad angeben.'; } else if (Number.isNaN(values.latitude) || values.latitude < -90 || values.latitude > 90) { errors.latitude = 'Breitengrad muss eine Zahl zwischen -90 und 90 sein.'; } if (values.longitude === null) { errors.longitude = 'Bitte einen Längengrad angeben.'; } else if (Number.isNaN(values.longitude) || values.longitude < -180 || values.longitude > 180) { errors.longitude = 'Längengrad muss eine Zahl zwischen -180 und 180 sein.'; } if (!errors.latitude && !errors.longitude && !isValidCoordinate(values.latitude, values.longitude)) { errors.latitude = 'Koordinaten sind ungültig.'; } if (values.ageMin !== null && (Number.isNaN(values.ageMin) || values.ageMin < 0)) { errors.ageMin = 'Bitte ein gültiges Alter (mindestens 0) angeben.'; } if (values.ageMax !== null && (Number.isNaN(values.ageMax) || values.ageMax < 0)) { errors.ageMax = 'Bitte ein gültiges Alter (mindestens 0) angeben.'; } if ( !errors.ageMin && !errors.ageMax && values.ageMin !== null && values.ageMax !== null && values.ageMin > values.ageMax ) { errors.ageMax = 'Der Wert "bis" darf nicht kleiner als "ab" sein.'; } if (values.duration !== null && (Number.isNaN(values.duration) || values.duration < 0)) { errors.duration = 'Bitte eine gültige Aufenthaltsdauer in Minuten (mindestens 0) angeben.'; } if (values.image && !isSafeHttpUrl(values.image)) { errors.image = 'Bitte eine gültige Bild-URL (http/https) angeben.'; } if (values.website && !isSafeHttpUrl(values.website)) { errors.website = 'Bitte eine gültige Website-URL (http/https) angeben.'; } return errors; } function clearErrors() { for (const key of ERROR_FIELDS) { const input = refs[key]; const errorEl = refs[`${key}Error`]; if (!input || !errorEl) continue; input.removeAttribute('aria-invalid'); errorEl.textContent = ''; errorEl.hidden = true; } } function showErrors(errors) { clearErrors(); for (const [key, message] of Object.entries(errors)) { const input = refs[key]; const errorEl = refs[`${key}Error`]; if (!input || !errorEl) continue; input.setAttribute('aria-invalid', 'true'); errorEl.textContent = message; errorEl.hidden = false; } } function focusFirstError(errors) { const firstKey = ERROR_FIELDS.find((key) => errors[key]); if (firstKey && refs[firstKey]) refs[firstKey].focus(); } /** Baut aus Formularwerten + optionaler Basis (bestehendes Place-Objekt) ein Rohobjekt für normalizePlace(). */ function buildRawPlace(values, base) { return { ...base, name: values.name, category: values.category, shortDescription: values.shortDescription, description: values.description, address: values.address, city: values.city, latitude: values.latitude, longitude: values.longitude, ageRecommendation: { min: values.ageMin, max: values.ageMax }, cost: { type: values.costType, description: values.costDescription }, openingHours: values.openingHours, durationMinutes: values.duration, facilities: { strollerAccessible: values.stroller, wheelchairAccessible: values.wheelchair, toilets: values.toilets, parking: values.parking, restaurant: values.restaurant, }, environment: values.environment, image: values.image, website: values.website, tags: values.tags .split(',') .map((tag) => tag.trim()) .filter(Boolean), // Explizit zurücksetzen: normalizePlace() setzt bei Bearbeiten dadurch // einen neuen Zeitstempel, statt den aus `base` übernommenen zu behalten. updatedAt: undefined, }; } function savePlace(normalized) { const state = getState(); const places = editingPlace ? state.places.map((p) => (p.id === normalized.id ? normalized : p)) : [...state.places, normalized]; setState({ places }); persistPlaces(); } function handleSubmit(event) { event.preventDefault(); const values = readValues(); const errors = validate(values); showErrors(errors); if (Object.keys(errors).length > 0) { focusFirstError(errors); return; } const base = editingPlace ? { ...editingPlace } : {}; const normalized = normalizePlace(buildRawPlace(values, base)); savePlace(normalized); closeForm(); } function prefillForm(place) { refs.name.value = place.name || ''; refs.category.value = place.category || ''; refs.shortDescription.value = place.shortDescription || ''; refs.description.value = place.description || ''; refs.address.value = place.address || ''; refs.city.value = place.city || ''; refs.latitude.value = place.latitude ?? ''; refs.longitude.value = place.longitude ?? ''; const age = place.ageRecommendation || {}; refs.ageMin.value = age.min ?? ''; refs.ageMax.value = age.max ?? ''; const cost = place.cost || {}; refs.costType.value = cost.type || 'unknown'; refs.costDescription.value = cost.description || ''; refs.openingHours.value = place.openingHours || ''; refs.duration.value = place.durationMinutes ?? ''; const facilities = place.facilities || {}; refs.stroller.checked = Boolean(facilities.strollerAccessible); refs.wheelchair.checked = Boolean(facilities.wheelchairAccessible); refs.toilets.checked = Boolean(facilities.toilets); refs.parking.checked = Boolean(facilities.parking); refs.restaurant.checked = Boolean(facilities.restaurant); refs.environment.value = place.environment || 'outdoor'; refs.image.value = place.image || ''; refs.website.value = place.website || ''; refs.tags.value = Array.isArray(place.tags) ? place.tags.join(', ') : ''; } /** Öffnet das Formular. Ohne Argument (bzw. mit null) im "Neu anlegen"-Modus, sonst im Bearbeiten-Modus. */ function openPlaceForm(place) { if (!container || !refs) { console.error('EventMap: Formular wurde noch nicht initialisiert (initForm()).'); return; } editingPlace = place || null; clearErrors(); refs.form.reset(); if (editingPlace) { refs.title.textContent = 'Ausflugsziel bearbeiten'; refs.submitBtn.textContent = 'Änderungen speichern'; prefillForm(editingPlace); } else { refs.title.textContent = 'Neues Ausflugsziel hinzufügen'; refs.submitBtn.textContent = 'Ausflugsziel speichern'; refs.category.value = ''; refs.costType.value = 'unknown'; refs.environment.value = 'outdoor'; } container.hidden = false; document.body.classList.add('place-form-modal-open'); refs.name.focus(); } function closeForm() { if (!container || container.hidden) return; container.hidden = true; document.body.classList.remove('place-form-modal-open'); editingPlace = null; } /** Initialisiert das Formular-Modal im Element mit der übergebenen id. */ function initForm(containerId) { const containerEl = document.getElementById(containerId); if (!containerEl) { console.error(`EventMap: Formular-Container #${containerId} nicht gefunden.`); return; } container = containerEl; refs = buildSkeleton(container); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !container.hidden) { closeForm(); } }); } EventMap.ui.initForm = initForm; EventMap.ui.openPlaceForm = openPlaceForm; })();