Files
eventmap/js/ui/detail.js
T
dapelza 10f9170d61 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.
2026-07-24 11:43:01 +02:00

459 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global L */
// Detailansicht (Modal) für ein einzelnes Ausflugsziel. Öffnet sich, sobald
// state.selectedPlaceId gesetzt wird (Marker-Klick oder Listen-Card),
// schließt sich per X, Escape oder Backdrop-Klick und setzt dabei
// selectedPlaceId im Store wieder auf null zurück.
//
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
// innerHTML mit Place-Daten (siehe js/utils/dom.js).
(function () {
const { subscribe, getState, setState, persistPlaces } = EventMap.store;
const { getPlaceCategory, getPlaceLatLng } = EventMap.models;
const { el, clearChildren, isSafeHttpUrl } = EventMap.utils;
const { isValidCoordinate } = EventMap.utils;
const FACILITY_DEFS = [
{ key: 'strollerAccessible', icon: '👶', label: 'Kinderwagen geeignet' },
{ key: 'wheelchairAccessible', icon: '♿', label: 'Barrierefrei' },
{ key: 'toilets', icon: '🚻', label: 'Toiletten vorhanden' },
{ key: 'parking', icon: '🅿️', label: 'Parkplatz vorhanden' },
{ key: 'restaurant', icon: '🍽️', label: 'Gastronomie vorhanden' },
];
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',
};
let refs = null;
let miniMap = null;
let lastSelectedPlaceId = null;
let currentPlace = null;
function formatAgeRecommendation(ageRecommendation) {
const { min, max } = ageRecommendation || {};
if (min == null && max == null) return 'Keine Angabe';
if (min != null && max != null) return `${min} ${max} Jahre`;
if (min != null) return `Ab ${min} Jahren`;
return `Bis ${max} Jahre`;
}
function formatDuration(minutes) {
if (typeof minutes !== 'number' || Number.isNaN(minutes) || minutes <= 0) {
return null;
}
if (minutes < 60) return `${minutes} Minuten`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} Std.` : `${hours} Std. ${rest} Min.`;
}
function buildSkeleton(container) {
const nodeRefs = {};
nodeRefs.backdrop = el('div', {
className: 'detail-modal__backdrop',
on: { click: () => setState({ selectedPlaceId: null }) },
});
nodeRefs.closeBtn = el(
'button',
{
className: 'detail-modal__close',
attrs: { type: 'button', 'aria-label': 'Detailansicht schließen' },
on: { click: () => setState({ selectedPlaceId: null }) },
},
['✕']
);
nodeRefs.imageWrap = el('div', { className: 'detail-image' });
nodeRefs.name = el('h2', { className: 'detail-name', attrs: { id: 'detail-name' } });
nodeRefs.categoryBadge = el('span', { className: 'detail-category' });
nodeRefs.description = el('p', { className: 'detail-description' });
nodeRefs.addressLines = el('address', { className: 'detail-address' });
nodeRefs.miniMapContainer = el('div', {
className: 'detail-minimap',
attrs: { id: 'detail-minimap' },
});
nodeRefs.miniMapNote = el('p', {
className: 'detail-minimap__note',
text: 'Keine gültigen Koordinaten hinterlegt.',
attrs: { hidden: '' },
});
nodeRefs.openingHours = el('p', { className: 'detail-value' });
nodeRefs.cost = el('p', { className: 'detail-value' });
nodeRefs.age = el('p', { className: 'detail-value' });
nodeRefs.facilitiesList = el('ul', { className: 'detail-facilities' });
nodeRefs.familyNotes = el('ul', { className: 'detail-family-notes' });
nodeRefs.tags = el('div', { className: 'detail-tags' });
nodeRefs.websiteWrap = el('p', { className: 'detail-value' });
nodeRefs.navBtn = el(
'a',
{
className: 'btn btn--primary detail-actions__nav',
attrs: { target: '_blank', rel: 'noopener noreferrer' },
},
['Route / Navigation starten']
);
nodeRefs.editBtn = el(
'button',
{
className: 'btn btn--secondary',
attrs: { type: 'button', id: 'detail-edit-btn' },
on: {
click: () => {
// Detailansicht schließen und das Bearbeiten-Formular öffnen
// (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);
},
},
},
['Bearbeiten']
);
nodeRefs.deleteBtn = el(
'button',
{
className: 'btn btn--danger',
attrs: { type: 'button', id: 'detail-delete-btn' },
on: { click: handleDelete },
},
['Löschen']
);
const body = el('div', { className: 'detail-body' }, [
nodeRefs.name,
nodeRefs.categoryBadge,
section('Beschreibung', [nodeRefs.description]),
section('Adresse', [nodeRefs.addressLines]),
section('Position', [nodeRefs.miniMapContainer, nodeRefs.miniMapNote]),
section('Öffnungszeiten', [nodeRefs.openingHours]),
section('Kosten', [nodeRefs.cost]),
section('Altersempfehlung', [nodeRefs.age]),
section('Ausstattung', [nodeRefs.facilitiesList]),
section('Hinweise für Familien', [nodeRefs.familyNotes, nodeRefs.tags]),
section('Website / Informationsquelle', [nodeRefs.websiteWrap]),
el('div', { className: 'detail-actions' }, [
nodeRefs.navBtn,
nodeRefs.editBtn,
nodeRefs.deleteBtn,
]),
]);
nodeRefs.panel = el(
'div',
{
className: 'detail-modal__panel',
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'detail-name' },
},
[nodeRefs.closeBtn, nodeRefs.imageWrap, body]
);
container.appendChild(nodeRefs.backdrop);
container.appendChild(nodeRefs.panel);
return nodeRefs;
}
function section(label, children) {
return el('section', { className: 'detail-section' }, [
el('h3', { className: 'detail-section__label', text: label }),
...children,
]);
}
function renderImage(place, category) {
clearChildren(refs.imageWrap);
if (isSafeHttpUrl(place.image)) {
const img = el('img', {
className: 'detail-image__img',
attrs: { src: place.image, alt: place.name },
});
// Falls die Bild-URL zwar ein sicheres Schema hat, aber nicht lädt
// (kaputter Link), auf das Platzhalterbild zurückfallen.
img.addEventListener('error', () => {
clearChildren(refs.imageWrap);
refs.imageWrap.appendChild(buildPlaceholderImage(category));
});
refs.imageWrap.appendChild(img);
} else {
refs.imageWrap.appendChild(buildPlaceholderImage(category));
}
}
function buildPlaceholderImage(category) {
return el(
'div',
{
className: 'detail-image__placeholder',
style: { backgroundColor: category.color },
},
[
el('span', { className: 'detail-image__placeholder-icon', text: category.icon }),
el('span', { className: 'detail-image__placeholder-text', text: 'Kein Bild vorhanden' }),
]
);
}
function renderFacilities(facilities) {
clearChildren(refs.facilitiesList);
for (const def of FACILITY_DEFS) {
const enabled = Boolean(facilities && facilities[def.key]);
refs.facilitiesList.appendChild(
el(
'li',
{
className: `detail-facility ${enabled ? 'detail-facility--yes' : 'detail-facility--no'}`,
},
[
el('span', { className: 'detail-facility__icon', attrs: { 'aria-hidden': 'true' }, text: def.icon }),
el('span', { className: 'detail-facility__label', text: def.label }),
el('span', {
className: 'detail-facility__state',
text: enabled ? '(vorhanden)' : '(nicht vorhanden)',
}),
]
)
);
}
}
function renderFamilyNotes(place) {
clearChildren(refs.familyNotes);
clearChildren(refs.tags);
const duration = formatDuration(place.durationMinutes);
if (duration) {
refs.familyNotes.appendChild(
el('li', { text: `Empfohlene Aufenthaltsdauer: ca. ${duration}` })
);
}
refs.familyNotes.appendChild(
el('li', {
text: `Umgebung: ${ENVIRONMENT_LABELS[place.environment] || 'Keine Angabe'}`,
})
);
if (Array.isArray(place.tags) && place.tags.length > 0) {
for (const tag of place.tags) {
refs.tags.appendChild(el('span', { className: 'detail-tag', text: tag }));
}
}
}
function renderWebsite(place) {
clearChildren(refs.websiteWrap);
if (isSafeHttpUrl(place.website)) {
refs.websiteWrap.appendChild(
el(
'a',
{
className: 'detail-website-link',
attrs: { href: place.website, target: '_blank', rel: 'noopener noreferrer' },
},
[place.website]
)
);
} else {
refs.websiteWrap.appendChild(
el('span', { className: 'detail-value--muted', text: 'Keine Website hinterlegt.' })
);
}
}
function renderAddress(place) {
clearChildren(refs.addressLines);
const lines = [place.address, [place.city, place.region].filter(Boolean).join(', ')].filter(
(line) => line && line.trim() !== ''
);
if (lines.length === 0) {
refs.addressLines.appendChild(
el('span', { className: 'detail-value--muted', text: 'Keine Adresse hinterlegt.' })
);
return;
}
lines.forEach((line, index) => {
if (index > 0) refs.addressLines.appendChild(el('br'));
refs.addressLines.appendChild(document.createTextNode(line));
});
}
function renderContent(place) {
currentPlace = place;
const category = getPlaceCategory(place);
refs.name.textContent = place.name;
refs.categoryBadge.textContent = `${category.icon} ${category.label}`;
refs.categoryBadge.style.backgroundColor = category.color;
renderImage(place, category);
refs.description.textContent = place.description || place.shortDescription || 'Keine Beschreibung vorhanden.';
renderAddress(place);
refs.openingHours.textContent = place.openingHours || 'Keine Angabe';
const cost = place.cost || {};
const costLabel = COST_LABELS[cost.type] || COST_LABELS.unknown;
refs.cost.textContent = cost.description ? `${costLabel} ${cost.description}` : costLabel;
refs.age.textContent = formatAgeRecommendation(place.ageRecommendation);
renderFacilities(place.facilities);
renderFamilyNotes(place);
renderWebsite(place);
setupNavButton(place);
}
function setupNavButton(place) {
const { lat, lng } = getPlaceLatLng(place);
if (isValidCoordinate(lat, lng)) {
refs.navBtn.hidden = false;
refs.navBtn.setAttribute(
'href',
`https://www.openstreetmap.org/directions?from=&to=${lat}%2C${lng}`
);
} else {
refs.navBtn.hidden = true;
}
}
function destroyMiniMap() {
if (miniMap) {
miniMap.remove();
miniMap = null;
}
}
function initMiniMap(place) {
destroyMiniMap();
const { lat, lng } = getPlaceLatLng(place);
if (!isValidCoordinate(lat, lng)) {
refs.miniMapContainer.hidden = true;
refs.miniMapNote.hidden = false;
return;
}
refs.miniMapContainer.hidden = false;
refs.miniMapNote.hidden = true;
miniMap = L.map(refs.miniMapContainer, {
center: [lat, lng],
zoom: 15,
});
// Dieselbe CARTO-Tile-Quelle wie die Hauptkarte (js/map/mapController.js):
// tile.openstreetmap.org blockt file://-Requests mangels HTTP-Referer.
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
subdomains: 'abcd',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(miniMap);
L.marker([lat, lng]).addTo(miniMap);
// Der Container war bis eben Teil eines hidden-Modals; Leaflet berechnet
// seine Größe beim Initialisieren daher ggf. falsch. invalidateSize()
// nach dem sichtbar werden behebt das bekannte Leaflet-Verhalten.
requestAnimationFrame(() => {
if (miniMap) miniMap.invalidateSize();
});
}
function handleDelete() {
const state = getState();
const place = state.places.find((p) => p.id === state.selectedPlaceId);
if (!place) return;
const confirmed = window.confirm(`"${place.name}" wirklich unwiderruflich löschen?`);
if (!confirmed) return;
const remainingPlaces = state.places.filter((p) => p.id !== place.id);
setState({ places: remainingPlaces, selectedPlaceId: null });
persistPlaces();
}
function openModal(container, place) {
renderContent(place);
container.hidden = false;
document.body.classList.add('detail-modal-open');
initMiniMap(place);
refs.closeBtn.focus();
}
function closeModal(container) {
if (container.hidden) return;
container.hidden = true;
document.body.classList.remove('detail-modal-open');
destroyMiniMap();
}
/** Initialisiert die Detailansicht im Element mit der übergebenen id und abonniert den Store. */
function initDetail(containerId) {
const container = document.getElementById(containerId);
if (!container) {
console.error(`EventMap: Detail-Container #${containerId} nicht gefunden.`);
return;
}
refs = buildSkeleton(container);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !container.hidden) {
setState({ selectedPlaceId: null });
}
});
const applyState = (state) => {
if (state.selectedPlaceId === lastSelectedPlaceId) return;
lastSelectedPlaceId = state.selectedPlaceId;
if (!state.selectedPlaceId) {
closeModal(container);
return;
}
const place = state.places.find((p) => p.id === state.selectedPlaceId);
if (!place) {
closeModal(container);
return;
}
openModal(container, place);
};
applyState(getState());
subscribe(applyState);
}
EventMap.ui.initDetail = initDetail;
})();