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.
459 lines
14 KiB
JavaScript
459 lines
14 KiB
JavaScript
/* 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:
|
||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende ' +
|
||
'© <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;
|
||
})();
|