Files
eventmap/js/ui/detail.js
T
dapelza 2812e03530 Phase 2: Detailansicht
Modal, das sich bei Marker- oder Listen-Auswahl (state.selectedPlaceId)
oeffnet: Titelbild/Platzhalter, vollstaendige Beschreibung, Adresse,
Mini-Karte mit Positionsmarker, Oeffnungszeiten, Kosten, Altersempfehlung,
Ausstattung (inkl. klar markierter negativer Werte), Hinweise fuer
Familien, Website-Link. Navigation-Button zu OSM Directions. Loeschen ist
bereits voll funktional (mit Bestaetigung), Bearbeiten ist als Platzhalter
fuer Phase 3 markiert.

Neuer Helper isSafeHttpUrl() in dom.js: nur http/https als Link- oder
Bildquelle zulaessig, schuetzt vor javascript:-URLs in Fremddaten
(Vorbereitung fuer KI-JSON-Import). Rendering weiterhin ausschliesslich
ueber textContent/el(), kein innerHTML mit Place-Daten.
2026-07-24 11:32:32 +02:00

452 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;
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: () => {
// TODO Phase 3: Formular zum Bearbeiten des Ausflugsziels öffnen.
window.alert('Bearbeiten ist ab Phase 3 verfügbar.');
},
},
},
['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) {
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;
})();