Phase 3: Formular zum Anlegen und Bearbeiten
Neues Modal (js/ui/form.js) mit allen Feldern aus anforderung.md Abschnitt 6: Name, Kategorie, Kurz-/Volltext-Beschreibung, Adresse, Ort, Koordinaten, Altersgruppe, Kosten, Oeffnungszeiten, Aufenthaltsdauer, Ausstattungs-Checkboxen, Indoor/Outdoor, Bild-URL, Website, Tags. Validierung: Pflichtfelder (Name, Kategorie, Koordinaten), Koordinaten- Bereichspruefung, Alter min<=max, Bild-/Website-URL nur http/https. Fehler erscheinen inline je Feld (aria-invalid/aria-describedby), erstes fehlerhaftes Feld wird fokussiert. EventMap.ui.openPlaceForm(place) dient als Einstiegspunkt: ohne Argument zum Neuanlegen, mit Place-Objekt zum Bearbeiten. Speichern aktualisiert den Store mit neuer Array-Referenz (Karte/Liste reagieren automatisch) und persistiert nach LocalStorage. Verdrahtung: neuer "+ Neues Ausflugsziel hinzufuegen"-Button in der Karten-Toolbar; der bisherige Bearbeiten-Platzhalter in der Detailansicht (js/ui/detail.js) oeffnet jetzt das Formular vorausgefuellt mit dem aktuell angezeigten Ausflugsziel.
This commit is contained in:
+175
@@ -0,0 +1,175 @@
|
|||||||
|
/* Formular-Modal zum Anlegen/Bearbeiten eines Ausflugsziels (Phase 3):
|
||||||
|
Vollbild auf Mobile, zentriertes Panel ab 1024px (siehe Breakpoint in
|
||||||
|
css/responsive.css). Struktur wird von js/ui/form.js in
|
||||||
|
#place-form-modal eingehängt. Grundgerüst analog zu css/detail.css. */
|
||||||
|
|
||||||
|
.place-form-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form-modal__panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-sm);
|
||||||
|
right: var(--space-sm);
|
||||||
|
z-index: 1;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__title {
|
||||||
|
padding: var(--space-lg) var(--space-lg) 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form {
|
||||||
|
padding: var(--space-md) var(--space-lg) var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-md) 0 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__section:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__section-label {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__row .form-field {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
font: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input[aria-invalid='true'],
|
||||||
|
.form-field select[aria-invalid='true'],
|
||||||
|
.form-field textarea[aria-invalid='true'] {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__error[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-form__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.place-form-modal__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(720px, 90vw);
|
||||||
|
height: min(90vh, 960px);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
top: var(--space-sm);
|
top: var(--space-sm);
|
||||||
left: var(--space-sm);
|
left: var(--space-sm);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-panel__map {
|
.map-panel__map {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<link rel="stylesheet" href="css/layout.css" />
|
<link rel="stylesheet" href="css/layout.css" />
|
||||||
<link rel="stylesheet" href="css/components.css" />
|
<link rel="stylesheet" href="css/components.css" />
|
||||||
<link rel="stylesheet" href="css/detail.css" />
|
<link rel="stylesheet" href="css/detail.css" />
|
||||||
|
<link rel="stylesheet" href="css/form.css" />
|
||||||
<link rel="stylesheet" href="css/responsive.css" />
|
<link rel="stylesheet" href="css/responsive.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -30,6 +31,9 @@
|
|||||||
<button id="locate-btn" type="button" class="btn btn--secondary">
|
<button id="locate-btn" type="button" class="btn btn--secondary">
|
||||||
Meinen Standort verwenden
|
Meinen Standort verwenden
|
||||||
</button>
|
</button>
|
||||||
|
<button id="add-place-btn" type="button" class="btn btn--primary">
|
||||||
|
+ Neues Ausflugsziel hinzufügen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="map" class="map-panel__map">
|
<div id="map" class="map-panel__map">
|
||||||
<p class="map-panel__loading">Karte wird geladen…</p>
|
<p class="map-panel__loading">Karte wird geladen…</p>
|
||||||
@@ -45,6 +49,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="detail-modal" class="detail-modal" hidden></div>
|
<div id="detail-modal" class="detail-modal" hidden></div>
|
||||||
|
<div id="place-form-modal" class="place-form-modal" hidden></div>
|
||||||
|
|
||||||
<script src="vendor/leaflet/leaflet.js"></script>
|
<script src="vendor/leaflet/leaflet.js"></script>
|
||||||
<script src="vendor/leaflet.markercluster/leaflet.markercluster.js"></script>
|
<script src="vendor/leaflet.markercluster/leaflet.markercluster.js"></script>
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
<script src="js/map/mapController.js"></script>
|
<script src="js/map/mapController.js"></script>
|
||||||
<script src="js/ui/list.js"></script>
|
<script src="js/ui/list.js"></script>
|
||||||
<script src="js/ui/detail.js"></script>
|
<script src="js/ui/detail.js"></script>
|
||||||
|
<script src="js/ui/form.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const { initStore } = EventMap.store;
|
const { initStore } = EventMap.store;
|
||||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||||
const { initList, initDetail } = EventMap.ui;
|
const { initList, initDetail, initForm } = EventMap.ui;
|
||||||
|
|
||||||
function showFatalError(message) {
|
function showFatalError(message) {
|
||||||
const status = document.getElementById('app-status');
|
const status = document.getElementById('app-status');
|
||||||
@@ -39,12 +39,23 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupAddPlaceButton() {
|
||||||
|
const button = document.getElementById('add-place-btn');
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
EventMap.ui.openPlaceForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await initStore();
|
await initStore();
|
||||||
initMap('map');
|
initMap('map');
|
||||||
initList('place-list');
|
initList('place-list');
|
||||||
initDetail('detail-modal');
|
initDetail('detail-modal');
|
||||||
|
initForm('place-form-modal');
|
||||||
setupGeolocationButton();
|
setupGeolocationButton();
|
||||||
|
setupAddPlaceButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
|
|||||||
+9
-2
@@ -37,6 +37,7 @@
|
|||||||
let refs = null;
|
let refs = null;
|
||||||
let miniMap = null;
|
let miniMap = null;
|
||||||
let lastSelectedPlaceId = null;
|
let lastSelectedPlaceId = null;
|
||||||
|
let currentPlace = null;
|
||||||
|
|
||||||
function formatAgeRecommendation(ageRecommendation) {
|
function formatAgeRecommendation(ageRecommendation) {
|
||||||
const { min, max } = ageRecommendation || {};
|
const { min, max } = ageRecommendation || {};
|
||||||
@@ -115,8 +116,13 @@
|
|||||||
attrs: { type: 'button', id: 'detail-edit-btn' },
|
attrs: { type: 'button', id: 'detail-edit-btn' },
|
||||||
on: {
|
on: {
|
||||||
click: () => {
|
click: () => {
|
||||||
// TODO Phase 3: Formular zum Bearbeiten des Ausflugsziels öffnen.
|
// Detailansicht schließen und das Bearbeiten-Formular öffnen
|
||||||
window.alert('Bearbeiten ist ab Phase 3 verfügbar.');
|
// (js/ui/form.js, wird nach diesem Modul geladen, daher hier zur
|
||||||
|
// Aufrufzeit über den globalen Namespace referenziert statt beim
|
||||||
|
// Datei-Laden destrukturiert).
|
||||||
|
const place = currentPlace;
|
||||||
|
setState({ selectedPlaceId: null });
|
||||||
|
EventMap.ui.openPlaceForm(place);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,6 +304,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(place) {
|
function renderContent(place) {
|
||||||
|
currentPlace = place;
|
||||||
const category = getPlaceCategory(place);
|
const category = getPlaceCategory(place);
|
||||||
|
|
||||||
refs.name.textContent = place.name;
|
refs.name.textContent = place.name;
|
||||||
|
|||||||
+604
@@ -0,0 +1,604 @@
|
|||||||
|
// 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;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user