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:
2026-07-24 11:32:32 +02:00
parent ee55fb1cf9
commit 2812e03530
6 changed files with 707 additions and 1 deletions
+13
View File
@@ -28,6 +28,19 @@
color: var(--color-primary-dark); color: var(--color-primary-dark);
} }
.btn--primary {
background: var(--color-primary);
color: #fff;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn--danger {
background: var(--color-danger);
color: #fff;
}
.map-panel__loading { .map-panel__loading {
padding: var(--space-md); padding: var(--space-md);
color: var(--color-text-muted); color: var(--color-text-muted);
+219
View File
@@ -0,0 +1,219 @@
/* Detailansicht (Modal): Vollbild auf Mobile, zentriertes Panel ab 1024px
(siehe Breakpoint in css/responsive.css). Struktur wird von js/ui/detail.js
in #detail-modal eingehängt. */
.detail-modal {
position: fixed;
inset: 0;
z-index: 2000;
}
.detail-modal[hidden] {
display: none;
}
.detail-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.detail-modal__panel {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--color-surface);
overflow-y: auto;
}
.detail-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;
}
.detail-image {
width: 100%;
height: 200px;
flex-shrink: 0;
background: var(--color-border);
}
.detail-image__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.detail-image__placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-xs);
color: #fff;
}
.detail-image__placeholder-icon {
font-size: 2.5rem;
}
.detail-image__placeholder-text {
font-size: 0.85rem;
}
.detail-body {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.detail-name {
font-size: 1.3rem;
font-weight: 700;
}
.detail-category {
align-self: flex-start;
color: #fff;
font-size: 0.8rem;
font-weight: 600;
padding: 2px var(--space-sm);
border-radius: 999px;
}
.detail-section__label {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--color-text-muted);
margin-bottom: var(--space-xs);
}
.detail-description {
line-height: 1.5;
white-space: pre-line;
}
.detail-address {
font-style: normal;
line-height: 1.4;
}
.detail-value {
line-height: 1.4;
}
.detail-value--muted {
color: var(--color-text-muted);
}
.detail-minimap {
width: 100%;
height: 220px;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-border);
}
.detail-minimap__note {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.detail-facilities {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.detail-facility {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.9rem;
}
.detail-facility--no {
color: var(--color-text-muted);
opacity: 0.7;
}
.detail-facility--no .detail-facility__icon {
filter: grayscale(1);
}
.detail-facility--no .detail-facility__label {
text-decoration: line-through;
}
.detail-facility__state {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.detail-family-notes {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-sm);
line-height: 1.4;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.detail-tag {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 2px var(--space-sm);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.detail-website-link {
color: var(--color-primary-dark);
word-break: break-word;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border);
}
@media (min-width: 1024px) {
.detail-modal__panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, 90vw);
height: min(85vh, 900px);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
}
}
+4
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="css/variables.css" /> <link rel="stylesheet" href="css/variables.css" />
<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/responsive.css" /> <link rel="stylesheet" href="css/responsive.css" />
</head> </head>
<body> <body>
@@ -43,6 +44,8 @@
</aside> </aside>
</main> </main>
<div id="detail-modal" class="detail-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>
@@ -65,6 +68,7 @@
<script src="js/map/markers.js"></script> <script src="js/map/markers.js"></script>
<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/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -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 } = EventMap.ui; const { initList, initDetail } = EventMap.ui;
function showFatalError(message) { function showFatalError(message) {
const status = document.getElementById('app-status'); const status = document.getElementById('app-status');
@@ -43,6 +43,7 @@
await initStore(); await initStore();
initMap('map'); initMap('map');
initList('place-list'); initList('place-list');
initDetail('detail-modal');
setupGeolocationButton(); setupGeolocationButton();
} }
+451
View File
@@ -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:
'&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;
})();
+18
View File
@@ -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.el = el;
EventMap.utils.escapeHtml = escapeHtml; EventMap.utils.escapeHtml = escapeHtml;
EventMap.utils.clearChildren = clearChildren; EventMap.utils.clearChildren = clearChildren;
EventMap.utils.isSafeHttpUrl = isSafeHttpUrl;
})(); })();