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:
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.el = el;
|
||||||
EventMap.utils.escapeHtml = escapeHtml;
|
EventMap.utils.escapeHtml = escapeHtml;
|
||||||
EventMap.utils.clearChildren = clearChildren;
|
EventMap.utils.clearChildren = clearChildren;
|
||||||
|
EventMap.utils.isSafeHttpUrl = isSafeHttpUrl;
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user