diff --git a/css/components.css b/css/components.css index 210de1d..22139f3 100644 --- a/css/components.css +++ b/css/components.css @@ -28,6 +28,19 @@ 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 { padding: var(--space-md); color: var(--color-text-muted); diff --git a/css/detail.css b/css/detail.css new file mode 100644 index 0000000..aab9a77 --- /dev/null +++ b/css/detail.css @@ -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); + } +} diff --git a/index.html b/index.html index e7b12c8..9579a88 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,7 @@ + @@ -43,6 +44,8 @@ + + @@ -65,6 +68,7 @@ + diff --git a/js/main.js b/js/main.js index 1f53d36..e78c082 100644 --- a/js/main.js +++ b/js/main.js @@ -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(); } diff --git a/js/ui/detail.js b/js/ui/detail.js new file mode 100644 index 0000000..df54983 --- /dev/null +++ b/js/ui/detail.js @@ -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: + '© OpenStreetMap-Mitwirkende ' + + '© CARTO', + }).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; +})(); diff --git a/js/utils/dom.js b/js/utils/dom.js index 84c608d..eafea21 100644 --- a/js/utils/dom.js +++ b/js/utils/dom.js @@ -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; })();