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);
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
+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/layout.css" />
|
||||
<link rel="stylesheet" href="css/components.css" />
|
||||
<link rel="stylesheet" href="css/detail.css" />
|
||||
<link rel="stylesheet" href="css/responsive.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -43,6 +44,8 @@
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="detail-modal" class="detail-modal" hidden></div>
|
||||
|
||||
<script src="vendor/leaflet/leaflet.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/mapController.js"></script>
|
||||
<script src="js/ui/list.js"></script>
|
||||
<script src="js/ui/detail.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+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