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.
This commit is contained in:
+2
-1
@@ -3,7 +3,7 @@
|
||||
(function () {
|
||||
const { initStore } = EventMap.store;
|
||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||
const { initList } = EventMap.ui;
|
||||
const { initList, initDetail } = EventMap.ui;
|
||||
|
||||
function showFatalError(message) {
|
||||
const status = document.getElementById('app-status');
|
||||
@@ -43,6 +43,7 @@
|
||||
await initStore();
|
||||
initMap('map');
|
||||
initList('place-list');
|
||||
initDetail('detail-modal');
|
||||
setupGeolocationButton();
|
||||
}
|
||||
|
||||
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
/* 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:
|
||||
'© <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;
|
||||
})();
|
||||
@@ -89,7 +89,25 @@ window.EventMap.ui = window.EventMap.ui || {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob eine URL sicher als Link/Bildquelle verwendet werden darf
|
||||
* (Scheme-Whitelist: nur http/https). Schützt vor javascript:-URLs & Co.
|
||||
* in Nutzer-/KI-generierten Feldern wie place.website oder place.image.
|
||||
*/
|
||||
function isSafeHttpUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
EventMap.utils.el = el;
|
||||
EventMap.utils.escapeHtml = escapeHtml;
|
||||
EventMap.utils.clearChildren = clearChildren;
|
||||
EventMap.utils.isSafeHttpUrl = isSafeHttpUrl;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user