Refactor: UI.loadLeaflet, leafletMarker, escape, emptyState, locationPicker zentralisiert

- Task 1: UI.loadLeaflet() in ui.js (mit Cluster-Option), lokale _loadLeaflet() in
  diary/walks/routes/places/poison/events.js entfernt
- Task 2: UI.escape() ersetzt lokale _esc()/_escape() in allen 5 Seiten-Modulen
- Task 3: UI.emptyState() ersetzt lokale _emptyState() in diary/routes/events.js
- Task 4: _fmtDate/_fmtDateShort in walks/poison bewusst behalten (anderes Format),
  Kommentare ergänzt
- Task 5: UI.locationPicker() eingebaut in places/poison/events (ersetzt manuelle
  GPS-Input-Blöcke)
- Task 6: UI.leafletMarker() factory in ui.js, Kreis-divIcon-Blöcke in walks/places/
  poison ersetzt; events.js behält Diamant-Marker (andere Form)
- SW by-v207, APP_VER 175
This commit is contained in:
rene 2026-04-18 14:34:35 +02:00
parent 066b722c5e
commit e98ce0d232
9 changed files with 761 additions and 471 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '173'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '175'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -17,26 +17,6 @@ window.Page_diary = (() => {
let _filterMilestone = false;
const LIMIT = 20;
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _sourceIcon(source) {
if (source === 'places') return 'star';
if (source === 'osm') return 'map-pin';
@ -70,7 +50,7 @@ window.Page_diary = (() => {
async function init(container, appState) {
_container = container;
_appState = appState;
_loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
UI.loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
await _render();
}
@ -139,14 +119,14 @@ window.Page_diary = (() => {
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_escape(dog.foto_url)}" alt="${_escape(dog.name)}">`
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${_escape(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${_escape(dog.rasse)}</div>` : ''}
<div class="diary-picker-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${UI.escape(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
@ -260,20 +240,6 @@ window.Page_diary = (() => {
UI.setLoading(btn, false);
}
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// LISTE RENDERN — Timeline gruppiert nach Monat
// ----------------------------------------------------------
@ -282,12 +248,12 @@ window.Page_diary = (() => {
if (!listEl) return;
if (_entries.length === 0) {
listEl.innerHTML = _emptyState(
'book-open',
'Noch keine Tagebucheinträge',
'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
`<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
);
listEl.innerHTML = UI.emptyState({
icon: UI.icon('book-open'),
title: 'Noch keine Tagebucheinträge',
text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`,
});
listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null));
return;
@ -339,11 +305,11 @@ window.Page_diary = (() => {
: '';
const locationHtml = e.location_name
? `<p class="diary-card-location"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${_escape(e.location_name)}</p>`
? `<p class="diary-card-location"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${UI.escape(e.location_name)}</p>`
: '';
const textPreview = e.text
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
? `<p class="diary-card-text">${UI.escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
@ -363,7 +329,7 @@ window.Page_diary = (() => {
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
<span class="diary-card-date">${dateStr}</span>
</div>
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${e.titel ? `<div class="diary-card-title">${UI.escape(e.titel)}</div>` : ''}
${locationHtml}
${textPreview}
${tagsHtml}
@ -378,8 +344,8 @@ window.Page_diary = (() => {
const avatars = dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
return `<div class="diary-dog-av" title="${UI.escape(dog.name)}">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
@ -408,9 +374,9 @@ window.Page_diary = (() => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(dog.name)}</span>
<span>${UI.escape(dog.name)}</span>
</div>` : '';
}).join('')}
</div>`
@ -428,11 +394,11 @@ window.Page_diary = (() => {
${entry.location_name ? `
<div class="diary-detail-location">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${_escape(entry.location_name)}</a>` : _escape(entry.location_name)}
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>` : UI.escape(entry.location_name)}
</div>` : ''}
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${UI.escape(entry.text)}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-3)">
@ -486,9 +452,9 @@ window.Page_diary = (() => {
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
${d.foto_url ? `<img src="${UI.escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(d.name)}</span>
<span>${UI.escape(d.name)}</span>
</label>`).join('')}
</div>
</div>` : '';
@ -507,12 +473,12 @@ window.Page_diary = (() => {
<div class="form-group">
<label class="form-label">Titel <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="titel"
value="${_escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${UI.escape(entry?.text || '')}</textarea>
</div>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
@ -532,7 +498,7 @@ window.Page_diary = (() => {
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-label">${_escape(entry?.location_name || '')}</span>
<span id="diary-location-label">${UI.escape(entry?.location_name || '')}</span>
<button type="button" id="diary-location-clear" aria-label="Name entfernen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
@ -549,7 +515,7 @@ window.Page_diary = (() => {
</div>
</div>
${dogPickerHtml}
<div class="form-group">
<div class="form-group" style="margin-top:var(--space-5)">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb"
${entry?.is_milestone ? 'checked' : ''} style="display:none">
<button type="button" id="diary-milestone-btn"
@ -810,7 +776,7 @@ window.Page_diary = (() => {
});
// Karte beim Formular-Open automatisch laden
_loadLeaflet().then(() => {
UI.loadLeaflet().then(() => {
setTimeout(() => {
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
_miniMap = L.map('diary-map-wrap', {
@ -854,9 +820,9 @@ window.Page_diary = (() => {
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${_escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
<span>${_escape(s.name)}</span>
<span>${UI.escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
@ -983,12 +949,6 @@ window.Page_diary = (() => {
.format(new Date(+y, +m - 1, 1));
}
function _escape(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// IMPORT
// ----------------------------------------------------------
@ -998,7 +958,7 @@ window.Page_diary = (() => {
body: `
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
Importiere Einträge aus einer anderen App in das Tagebuch von
<strong>${_escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
@ -1076,7 +1036,7 @@ window.Page_diary = (() => {
const errHtml = res.errors?.length
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${_escape(res.errors.join('\n'))}</pre></details>`
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${UI.escape(res.errors.join('\n'))}</pre></details>`
: '';
resultEl.innerHTML = `
@ -1100,7 +1060,7 @@ window.Page_diary = (() => {
resultEl.innerHTML = `
<div style="background:var(--c-danger-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-danger)">
Fehler: ${_escape(e.message || String(e))}
Fehler: ${UI.escape(e.message || String(e))}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);

View file

@ -48,16 +48,7 @@ window.Page_events = (() => {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// _emptyState ersetzt durch UI.emptyState()
// ----------------------------------------------------------
// init
@ -145,11 +136,11 @@ window.Page_events = (() => {
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = _emptyState(
'calendar-blank',
'Keine Events in der Nähe',
'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
);
listEl.innerHTML = UI.emptyState({
icon: UI.icon('calendar-blank'),
title: 'Keine Events in der Nähe',
text: 'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.',
});
return;
}
@ -220,8 +211,7 @@ window.Page_events = (() => {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await _loadLeaflet();
await _loadMarkerCluster();
await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
@ -242,6 +232,7 @@ window.Page_events = (() => {
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg)"><span style="transform:rotate(45deg)">${typ.icon}</span></div>`,
@ -281,60 +272,7 @@ window.Page_events = (() => {
setTimeout(() => _map.invalidateSize(), 100);
}
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _loadMarkerCluster() {
if (window.L && L.markerClusterGroup) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="MarkerCluster"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/MarkerCluster.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
const link2 = document.createElement('link');
link2.rel = 'stylesheet';
link2.href = '/css/MarkerCluster.Default.css';
link2.onload = res;
link2.onerror = res;
document.head.appendChild(link2);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="markercluster"]') ||
document.querySelector('script[src*="MarkerCluster"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js';
s.onload = resolve;
s.onerror = resolve; // Cluster ist optional — graceful degradation
document.head.appendChild(s);
});
});
}
// _loadLeaflet und _loadMarkerCluster ersetzt durch UI.loadLeaflet(true)
// ----------------------------------------------------------
// Detail-Modal
@ -520,17 +458,10 @@ window.Page_events = (() => {
<label class="form-label">Ort / Veranstaltungsort</label>
<input class="form-control" name="ort_name" placeholder="z.B. Stadtpark München" value="${ev?.ort_name || ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Breitengrad</label>
<input class="form-control" type="number" step="any" name="lat" id="ev-lat" placeholder="48.1234" value="${ev?.lat || ''}">
</div>
<div class="form-group">
<label class="form-label">Längengrad</label>
<input class="form-control" type="number" step="any" name="lon" id="ev-lon" placeholder="11.5678" value="${ev?.lon || ''}">
</div>
<div class="form-group">
<label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">${_icon('map-pin')} GPS-Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
@ -563,30 +494,31 @@ window.Page_events = (() => {
if (ok) await _deleteEvent(ev);
});
document.getElementById('ev-gps-btn')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
document.getElementById('ev-lat').value = pos.lat.toFixed(6);
document.getElementById('ev-lon').value = pos.lon.toFixed(6);
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
// Location-Picker initialisieren
const _picker = UI.locationPicker({
containerId: 'ev-location-picker',
});
if (ev?.lat && ev?.lon) {
_picker.setValue(ev.lat, ev.lon, ev.ort_name || null);
}
const form = document.getElementById(id);
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
form.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(form);
const fd = new FormData(form);
const loc = _picker.getValue();
const data = {
titel: fd.get('titel'),
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit') || null,
typ: fd.get('typ'),
ort_name: fd.get('ort_name') || null,
lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null,
lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null,
titel: fd.get('titel'),
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit') || null,
typ: fd.get('typ'),
ort_name: loc.name || fd.get('ort_name') || null,
lat: loc.lat || null,
lon: loc.lon || null,
beschreibung: fd.get('beschreibung') || null,
link: fd.get('link') || null,
link: fd.get('link') || null,
};
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {

View file

@ -11,7 +11,6 @@ window.Page_places = (() => {
let _markers = [];
let _data = [];
let _activeTyp = null; // null = alle
let _leafletLoaded = false;
let _userPos = null;
// ----------------------------------------------------------
@ -26,9 +25,7 @@ window.Page_places = (() => {
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
};
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// _esc ersetzt durch UI.escape()
// ----------------------------------------------------------
// INIT
@ -98,25 +95,7 @@ window.Page_places = (() => {
_showForm(null);
});
_loadLeaflet().then(_initMap);
}
// ----------------------------------------------------------
// Leaflet laden (wie poison.js)
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
document.head.appendChild(link);
await new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
document.head.appendChild(s);
});
_leafletLoaded = true;
UI.loadLeaflet().then(_initMap);
}
// ----------------------------------------------------------
@ -191,20 +170,8 @@ window.Page_places = (() => {
_markers = [];
_filtered().forEach(place => {
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
const icon = L.divIcon({
className: '',
html: `<div style="
background:${t.color};color:#fff;font-size:16px;
width:34px;height:34px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(255,255,255,0.7)
">${t.icon}</div>`,
iconSize: [34, 34],
iconAnchor: [17, 17],
});
const marker = L.marker([place.lat, place.lon], { icon })
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
const marker = UI.leafletMarker({ lat: place.lat, lon: place.lon, color: t.color, icon: t.icon, size: 34 })
.addTo(_map)
.on('click', () => _openDetail(place));
_markers.push(marker);
@ -253,10 +220,10 @@ window.Page_places = (() => {
<div class="places-card" data-id="${p.id}" style="--typ-color:${t.color}">
<div class="places-card-icon">${t.icon}</div>
<div class="places-card-body">
<div class="places-card-name">${_esc(p.name)}</div>
<div class="places-card-name">${UI.escape(p.name)}</div>
<div class="places-card-meta">
<span class="places-card-typ" style="color:${t.color}">${t.label}</span>
${p.adresse ? `· <span>${_esc(p.adresse)}</span>` : ''}
${p.adresse ? `· <span>${UI.escape(p.adresse)}</span>` : ''}
</div>
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
</div>
@ -281,16 +248,17 @@ window.Page_places = (() => {
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<div style="font-size:2.5rem">${t.icon}</div>
<div>
<div style="font-size:1.1rem;font-weight:600">${_esc(place.name)}</div>
<div style="font-size:1.1rem;font-weight:600">${UI.escape(place.name)}</div>
<div style="color:${t.color};font-size:0.9rem">${t.label}</div>
</div>
</div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${_esc(place.adresse)}</p>` : ''}
${place.telefon ? `<p style="margin-bottom:var(--space-2)"><a href="tel:${_esc(place.telefon)}" style="color:var(--c-primary)">${UI.icon('phone')} ${_esc(place.telefon)}</a></p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${_esc(place.website)}</a></p>` : ''}
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${UI.escape(place.adresse)}</p>` : ''}
${place.telefon ? `<p style="margin-bottom:var(--space-2)"><a href="tel:${UI.escape(place.telefon)}" style="color:var(--c-primary)">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${UI.escape(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<div id="place-rating-${place.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Eingetragen von ${_esc(place.user_name || 'Unbekannt')}
Eingetragen von ${UI.escape(place.user_name || 'Unbekannt')}
</p>
`;
@ -301,7 +269,14 @@ window.Page_places = (() => {
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
`;
UI.modal.open({ title: `${t.icon} ${_esc(place.name)}`, body, footer });
UI.modal.open({ title: `${t.icon} ${UI.escape(place.name)}`, body, footer });
UI.ratingStars({
containerId: `place-rating-${place.id}`,
targetType: 'place',
targetId: place.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
@ -331,7 +306,7 @@ window.Page_places = (() => {
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" type="text" name="name"
value="${_esc(place?.name || '')}" placeholder="z. B. Café Hund & Herrchen" required>
value="${UI.escape(place?.name || '')}" placeholder="z. B. Café Hund & Herrchen" required>
</div>
<div class="form-group">
@ -341,38 +316,25 @@ window.Page_places = (() => {
<div class="form-group">
<label class="form-label">GPS-Position *</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="pf-lat-disp"
placeholder="Breite" readonly style="flex:1"
value="${place ? place.lat.toFixed(6) : ''}">
<input class="form-control" type="text" id="pf-lon-disp"
placeholder="Länge" readonly style="flex:1"
value="${place ? place.lon.toFixed(6) : ''}">
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<input type="hidden" name="lat" id="pf-lat" value="${place?.lat || ''}">
<input type="hidden" name="lon" id="pf-lon" value="${place?.lon || ''}">
<small id="pf-gps-hint" style="color:var(--c-text-secondary)">
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
</small>
<div id="pf-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Adresse <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="adresse"
value="${_esc(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div>
<div class="form-group">
<label class="form-label">Website <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="url" name="website"
value="${_esc(place?.website || '')}" placeholder="https://…">
value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div>
<div class="form-group">
<label class="form-label">Telefon <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="tel" name="telefon"
value="${_esc(place?.telefon || '')}" placeholder="+49 89 123456">
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</div>
<div class="form-group" style="display:flex;flex-direction:column;gap:var(--space-2)">
@ -406,7 +368,7 @@ window.Page_places = (() => {
</div>
`;
UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
UI.modal.open({ title: isEdit ? `${UI.escape(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
@ -425,30 +387,19 @@ window.Page_places = (() => {
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
// GPS-Button
document.getElementById('pf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('pf-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ enableHighAccuracy: true });
_userPos = pos;
document.getElementById('pf-lat').value = pos.lat;
document.getElementById('pf-lon').value = pos.lon;
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
} catch {
UI.toast.error('GPS nicht verfügbar.');
}
UI.setLoading(btn, false);
});
// Location-Picker initialisieren
const _picker = UI.locationPicker({ containerId: 'pf-location-picker' });
if (place?.lat && place?.lon) {
_picker.setValue(place.lat, place.lon, null);
}
document.getElementById('place-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="place-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const loc = _picker.getValue();
if (!fd.lat || !fd.lon) {
if (!loc.lat || !loc.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln.');
return;
}
@ -457,8 +408,8 @@ window.Page_places = (() => {
const payload = {
name: fd.name?.trim(),
typ: fd.typ,
lat: parseFloat(fd.lat),
lon: parseFloat(fd.lon),
lat: loc.lat,
lon: loc.lon,
adresse: fd.adresse || null,
website: fd.website || null,
telefon: fd.telefon || null,

View file

@ -15,7 +15,6 @@ window.Page_poison = (() => {
let _userMarker = null;
let _reports = [];
let _userPos = null;
let _leafletLoaded = false;
const TYPEN = {
unbekannt: { label: 'Unbekannt', icon: '❓', color: '#e67e22' },
@ -97,43 +96,13 @@ window.Page_poison = (() => {
document.getElementById('poison-btn-report')
?.addEventListener('click', _showReportForm);
await _loadLeaflet();
await UI.loadLeaflet();
_initMap();
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
}
// ----------------------------------------------------------
// LEAFLET DYNAMISCH LADEN
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// CSS lokal (kein CDN)
await new Promise(resolve => {
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = resolve;
link.onerror = resolve;
document.head.appendChild(link);
});
// JS lokal (kein CDN)
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
@ -229,27 +198,16 @@ window.Page_poison = (() => {
_reports.forEach(r => {
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
const icon = L.divIcon({
className : '',
html : `<div style="
background:${typ.color};color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">${typ.icon}</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m < 1000
? `${r.distanz_m} m`
: `${(r.distanz_m / 1000).toFixed(1)} km`;
const marker = L.marker([r.lat, r.lon], { icon })
const marker = UI.leafletMarker({ lat: r.lat, lon: r.lon, color: typ.color, icon: typ.icon, size: 34 })
.addTo(_map)
.bindPopup(`
<b>${typ.icon} ${typ.label}</b><br>
${r.beschreibung ? _escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small>
${r.bestaetigt ? '<br><small>✅ Bestätigt</small>' : ''}
@ -316,7 +274,7 @@ window.Page_poison = (() => {
${r.beschreibung
? `<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>`
: ''}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
@ -357,7 +315,7 @@ window.Page_poison = (() => {
</div>
${r.beschreibung
? `<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">${_escape(r.beschreibung)}</p>`
? `<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">${UI.escape(r.beschreibung)}</p>`
: ''}
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
@ -365,7 +323,7 @@ window.Page_poison = (() => {
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
<div> Läuft ab: ${_fmtDate(r.expires_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name)}</div>` : ''}
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
@ -482,21 +440,7 @@ window.Page_poison = (() => {
<div class="form-group">
<label class="form-label">Standort</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="pf-lat-disp"
placeholder="Breite" readonly style="flex:1">
<input class="form-control" type="text" id="pf-lon-disp"
placeholder="Länge" readonly style="flex:1">
<button type="button" class="btn btn-secondary" id="pf-gps-btn"
title="GPS-Standort ermitteln">📍</button>
</div>
<input type="hidden" name="lat" id="pf-lat">
<input type="hidden" name="lon" id="pf-lon">
<small id="pf-gps-hint" style="color:var(--c-text-secondary)">
${_userPos
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
</small>
<div id="poison-location-picker"></div>
</div>
<div class="form-group">
@ -532,32 +476,12 @@ window.Page_poison = (() => {
UI.modal.open({ title: '⚠️ Giftköder melden', body, footer });
// Standort vorausfüllen wenn bekannt
// Location-Picker initialisieren + ggf. bekannten Standort vorausfüllen
const _picker = UI.locationPicker({ containerId: 'poison-location-picker' });
if (_userPos) {
document.getElementById('pf-lat').value = _userPos.lat;
document.getElementById('pf-lon').value = _userPos.lon;
document.getElementById('pf-lat-disp').value = _userPos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = _userPos.lon.toFixed(6);
_picker.setValue(_userPos.lat, _userPos.lon, null);
}
// GPS-Button
document.getElementById('pf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('pf-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true });
document.getElementById('pf-lat').value = pos.lat;
document.getElementById('pf-lon').value = pos.lon;
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('pf-gps-hint').textContent = '✅ Standort aktualisiert';
_userPos = pos;
} catch {
UI.toast.error('GPS-Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
});
// Foto-Vorschau
const photoInput = document.querySelector('#poison-form [name="photo"]');
const photoPreview = document.getElementById('pf-photo-preview');
@ -576,16 +500,17 @@ window.Page_poison = (() => {
e.preventDefault();
const submitBtn = document.querySelector('[form="poison-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const loc = _picker.getValue();
if (!fd.lat || !fd.lon) {
if (!loc.lat || !loc.lon) {
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
return;
}
await UI.asyncButton(submitBtn, async () => {
const payload = {
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
lat : loc.lat,
lon : loc.lon,
typ : fd.typ,
beschreibung : fd.beschreibung || null,
};
@ -605,6 +530,8 @@ window.Page_poison = (() => {
}
// Distanz client-seitig berechnen (für sofortige Anzeige)
// _userPos aktualisieren falls Picker neuen Standort geliefert hat
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
: 0;
@ -644,6 +571,8 @@ window.Page_poison = (() => {
return 2 * R * Math.asin(Math.sqrt(a));
}
// _fmtDate: bewusst lokal behalten — UI.time.format() liefert langen Monats-Namen
// und behandelt kein SQLite-Leerzeichen-Format ("2026-04-12 00:00:00")
function _fmtDate(isoStr) {
if (!isoStr) return '';
// SQLite speichert "2026-04-12T00:00:00" oder "2026-04-12 00:00:00"
@ -653,15 +582,6 @@ window.Page_poison = (() => {
});
}
function _escape(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -27,8 +27,6 @@ window.Page_routes = (() => {
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
let _leafletReady = false;
const DIFFICULTY_LABEL = { leicht: '🟢 Leicht', mittel: '🟡 Mittel', anspruchsvoll: '🔴 Anspruchsvoll' };
const TERRAIN_LABEL = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' };
const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' };
@ -41,26 +39,13 @@ window.Page_routes = (() => {
{ type: 'bank', icon: '🪑', label: 'Bank' },
];
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// _esc und _emptyState ersetzt durch UI.escape() / UI.emptyState()
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
UI.loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
try { _userPos = await API.getLocation(); } catch {}
await _loadData();
@ -72,21 +57,6 @@ window.Page_routes = (() => {
}
}
async function _loadLeaflet() {
if (_leafletReady || window.L) { _leafletReady = true; return; }
if (!document.querySelector('link[href="/css/leaflet.css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = '/css/leaflet.css';
document.head.appendChild(l);
}
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
_leafletReady = true;
}
function refresh() { _loadData(); }
function onDogChange() {}
@ -362,7 +332,7 @@ window.Page_routes = (() => {
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
const tip = `<b>${UI.escape(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
@ -409,7 +379,7 @@ window.Page_routes = (() => {
_applyFilter();
} catch (err) {
document.getElementById('rk-grid').innerHTML =
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${_esc(err.message)}</p>`;
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${UI.escape(err.message)}</p>`;
}
}
@ -478,12 +448,12 @@ window.Page_routes = (() => {
</div>`;
} else {
// Noch gar keine eigenen Routen
grid.innerHTML = _emptyState(
'map-trifold',
'Noch keine Routen',
'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
`<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`
);
grid.innerHTML = UI.emptyState({
icon: UI.icon('map-trifold'),
title: 'Noch keine Routen',
text: 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
action: `<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`,
});
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
@ -530,13 +500,13 @@ window.Page_routes = (() => {
const dur = r.dauer_min ? _fmtDur(r.dauer_min) : '';
const firstPhoto = (r.foto_urls || [])[0];
const previewContent = firstPhoto
? `<img src="${_esc(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
? `<img src="${UI.escape(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
: `<div class="rk-mini-map" data-id="${r.id}"
data-track='${JSON.stringify(r.preview_track||[])}'
style="width:100%;height:100%"></div>`;
const authorLine = isDiscover
? `<div class="rk-card-creator">${UI.icon('user')} ${_esc(r.user_name||'Anonym')}</div>`
? `<div class="rk-card-creator">${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}</div>`
: '';
return `
@ -544,7 +514,7 @@ window.Page_routes = (() => {
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
${authorLine}
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-name">${UI.escape(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
${dur ? `<span>${UI.icon('timer')} ${dur}</span>` : ''}
@ -560,7 +530,7 @@ window.Page_routes = (() => {
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
${isDiscover ? '' : `<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>`}
${isDiscover ? '' : `<span class="rk-card-author">${UI.escape(r.user_name||'Anonym')}</span>`}
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>
@ -661,7 +631,7 @@ window.Page_routes = (() => {
const photoGallery = photos.length ? `
<div class="rk-photo-gallery">
${photos.map(u => `<img src="${_esc(u)}" class="rk-photo-thumb" onclick="window.open('${_esc(u)}','_blank')">`).join('')}
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" onclick="window.open('${UI.escape(u)}','_blank')">`).join('')}
${isOwn ? `<label class="rk-photo-add" title="Foto hinzufügen">
<span>+</span>
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
@ -686,13 +656,13 @@ window.Page_routes = (() => {
${route.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine empfohlen</span>` : ''}
${!route.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : ''}
</div>
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${_esc(route.beschreibung)}</p>` : ''}
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${UI.escape(route.beschreibung)}</p>` : ''}
<div id="rk-nearby" class="rk-nearby-section">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt Orte entlang der Route</div>
</div>
<div id="rk-rating-${route.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-2)">
${track.length} GPS-Punkte · von ${_esc(route.user_name||'Anonym')}
${route.bewertung ? ` · ${UI.icon('star')} ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}
${track.length} GPS-Punkte · von ${UI.escape(route.user_name||'Anonym')}
</p>
`;
@ -707,7 +677,14 @@ window.Page_routes = (() => {
<button type="button" class="btn btn-primary flex-1" id="rd-close">Schließen</button>
`;
UI.modal.open({ title: `🥾 ${_esc(route.name)}`, body, footer });
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer });
UI.ratingStars({
containerId: `rk-rating-${route.id}`,
targetType: 'route',
targetId: route.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
@ -855,12 +832,12 @@ window.Page_routes = (() => {
<div class="rk-nearby-title">${UI.icon('map-pin')} Entlang der Route</div>
${Object.values(byType).map(group => `
<div class="rk-nearby-group">
<div class="rk-nearby-group-label">${group.icon} ${_esc(group.label)} (${group.items.length})</div>
<div class="rk-nearby-group-label">${group.icon} ${UI.escape(group.label)} (${group.items.length})</div>
${group.items.slice(0, 5).map(p => `
<div class="rk-nearby-item">
<span class="rk-nearby-name">${_esc(p.name || group.label)}</span>
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${_esc(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${_esc(p.phone)}" class="rk-nearby-detail rk-nearby-phone">${UI.icon('phone')} ${_esc(p.phone)}</a>` : ''}
<span class="rk-nearby-name">${UI.escape(p.name || group.label)}</span>
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${UI.escape(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${UI.escape(p.phone)}" class="rk-nearby-detail rk-nearby-phone">${UI.icon('phone')} ${UI.escape(p.phone)}</a>` : ''}
</div>
`).join('')}
${group.items.length > 5 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 0">+${group.items.length-5} weitere</div>` : ''}
@ -898,7 +875,7 @@ window.Page_routes = (() => {
const pts = track.map(p => ` <trkpt lat="${p.lat}" lon="${p.lon}"></trkpt>`).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Ban Yaro" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>${_esc(route.name)}</name><trkseg>\n${pts}\n </trkseg></trk>
<trk><name>${UI.escape(route.name)}</name><trkseg>\n${pts}\n </trkseg></trk>
</gpx>`;
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
@ -1093,7 +1070,7 @@ window.Page_routes = (() => {
<form id="rk-import-form" style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-input" id="ri-name" value="${_esc(name)}" required maxlength="120">
<input class="form-input" id="ri-name" value="${UI.escape(name)}" required maxlength="120">
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
@ -1225,15 +1202,15 @@ window.Page_routes = (() => {
const friendRows = friends.map(f => {
const initial = (f.name || '?')[0].toUpperCase();
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${_esc(f.name || 'Anonym')}"
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:600;flex-shrink:0">${_esc(initial)}</div>
<span>${_esc(f.name || 'Anonym')}</span>
font-weight:600;flex-shrink:0">${UI.escape(initial)}</div>
<span>${UI.escape(f.name || 'Anonym')}</span>
</div>`;
}).join('');

View file

@ -11,21 +11,20 @@ window.Page_walks = (() => {
let _view = 'liste'; // 'liste' | 'karte'
let _map = null;
let _markers = [];
let _leafletLoaded = false;
let _userPos = null;
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// _esc ersetzt durch UI.escape()
// Datum deutsch formatieren: "2026-04-20" → "Sonntag, 20. April 2026"
// Hinweis: UI.time.format() liefert kein weekday — daher lokale Funktion beibehalten
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
// Datum kurz: "So, 20.04."
// Datum kurz: "So, 20.04." — UI.time.formatShort() gibt "20. Apr." ohne Wochentag
// Hinweis: Format nicht äquivalent zu UI.time.formatShort() — daher lokal beibehalten
function _fmtDateShort(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
@ -116,7 +115,7 @@ window.Page_walks = (() => {
document.getElementById('walks-map-view').style.display = view === 'karte' ? '' : 'none';
if (view === 'karte') {
_loadLeaflet().then(() => {
UI.loadLeaflet().then(() => {
_initMap();
setTimeout(() => _map?.invalidateSize(), 150);
setTimeout(() => _map?.invalidateSize(), 400);
@ -196,8 +195,8 @@ window.Page_walks = (() => {
<div class="walks-card-time">${w.uhrzeit}</div>
</div>
<div class="walks-card-body">
<div class="walks-card-title">${_esc(w.titel)}</div>
${w.ort_name ? `<div class="walks-card-ort">${UI.icon('map-pin')} ${_esc(w.ort_name)}</div>` : ''}
<div class="walks-card-title">${UI.escape(w.titel)}</div>
${w.ort_name ? `<div class="walks-card-ort">${UI.icon('map-pin')} ${UI.escape(w.ort_name)}</div>` : ''}
<div class="walks-card-meta">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
@ -213,28 +212,6 @@ window.Page_walks = (() => {
// ----------------------------------------------------------
// Leaflet + Karte
// ----------------------------------------------------------
function _loadLeaflet() {
if (window.L) { _leafletLoaded = true; return Promise.resolve(); }
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { _leafletLoaded = true; resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = () => { _leafletLoaded = true; resolve(); };
s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _initMap() {
const el = document.getElementById('walks-map');
if (!el || !window.L || _map) return;
@ -253,15 +230,7 @@ window.Page_walks = (() => {
if (!w.lat || !w.lon) return;
const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer;
const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E');
const icon = L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
border:2px solid rgba(255,255,255,0.8)">${UI.icon('dog')}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const m = L.marker([w.lat, w.lon], { icon })
const m = UI.leafletMarker({ lat: w.lat, lon: w.lon, color, icon: UI.icon('dog') })
.addTo(_map)
.bindTooltip(`${w.titel} · ${_fmtDateShort(w.datum)} ${w.uhrzeit}`, { direction: 'top', offset: [0,-16] })
.on('click', () => _openDetail(w.id));
@ -292,8 +261,8 @@ window.Page_walks = (() => {
<div class="walks-invitation-row">
<div class="walks-inv-avatar">${_avatarInitials(inv.user_name)}</div>
<div class="walks-inv-info">
<div class="walks-inv-name">${_esc(inv.user_name)}</div>
${inv.hunde ? `<div class="walks-inv-hunde">${UI.icon('dog')} ${_esc(inv.hunde)}</div>` : ''}
<div class="walks-inv-name">${UI.escape(inv.user_name)}</div>
${inv.hunde ? `<div class="walks-inv-hunde">${UI.icon('dog')} ${UI.escape(inv.hunde)}</div>` : ''}
</div>
<div class="walks-inv-badge">${_rsvpBadge(inv.status)}</div>
</div>`;
@ -328,8 +297,8 @@ window.Page_walks = (() => {
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<span class="walks-participant-name">${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
<span class="walks-participant-name">${UI.escape(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${UI.escape(t.hunde)}</span>` : ''}
</div>`).join('')
: '';
@ -362,7 +331,7 @@ window.Page_walks = (() => {
${_fmtDate(walk.datum)}<br>
<strong>um ${walk.uhrzeit} Uhr</strong>
</div>
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(walk.ort_name)}</div>` : ''}
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${UI.escape(walk.ort_name)}</div>` : ''}
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
@ -373,7 +342,7 @@ window.Page_walks = (() => {
</div>
${walk.beschreibung ? `
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${UI.escape(walk.beschreibung)}</p>
` : ''}
${rsvpSectionHTML}
@ -393,8 +362,13 @@ window.Page_walks = (() => {
</div>
` : ''}
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('star')} Bewertung</div>
<div id="wd-rating-${walk.id}"></div>
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
</p>
${isOwn && !isPast ? `
@ -440,6 +414,14 @@ window.Page_walks = (() => {
UI.modal.open({ title: `${UI.icon('dog')} ${walk.titel}`, body, footer });
// Bewertungskomponente initialisieren (nur nach abgelaufenem Treffen sinnvoll, aber immer anzeigen)
UI.ratingStars({
containerId: `wd-rating-${walk.id}`,
targetType: 'walk',
targetId: walk.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('wd-login')?.addEventListener('click', () => {
@ -553,9 +535,9 @@ window.Page_walks = (() => {
const listHTML = candidates.length
? candidates.map(f => `
<div class="walks-invite-row" data-friend-id="${f.friend_id}" data-friend-name="${_esc(f.friend_name)}">
<div class="walks-invite-row" data-friend-id="${f.friend_id}" data-friend-name="${UI.escape(f.friend_name)}">
<div class="walks-inv-avatar">${_avatarInitials(f.friend_name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(f.friend_name)}</div>
<div class="walks-inv-name" style="flex:1">${UI.escape(f.friend_name)}</div>
<button type="button" class="btn btn-primary btn-sm walks-invite-send">
${UI.icon('paper-plane-tilt')} Einladen
</button>
@ -565,7 +547,7 @@ window.Page_walks = (() => {
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr
${walk.ort_name ? `· ${_esc(walk.ort_name)}` : ''}
${walk.ort_name ? `· ${UI.escape(walk.ort_name)}` : ''}
</p>
<div id="invite-list">${listHTML}</div>
`;
@ -590,7 +572,7 @@ window.Page_walks = (() => {
await API.walks.invite(walk.id, friendId);
row.innerHTML = `
<div class="walks-inv-avatar">${_avatarInitials(name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(name)}</div>
<div class="walks-inv-name" style="flex:1">${UI.escape(name)}</div>
<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>
`;
UI.toast.success(`${name} eingeladen.`);
@ -609,14 +591,14 @@ window.Page_walks = (() => {
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
padding:var(--space-2) 0">
<input type="checkbox" name="dog" value="${d.id}" checked>
${UI.icon('dog')} ${_esc(d.name)}
${UI.icon('dog')} ${UI.escape(d.name)}
</label>`).join('')
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}
${walk.ort_name ? `${UI.icon('map-pin')} ${UI.escape(walk.ort_name)}` : ''}
</p>
<form id="join-form" autocomplete="off">
<div class="form-group">
@ -682,7 +664,7 @@ window.Page_walks = (() => {
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
value="${_esc(v.titel || '')}"
value="${UI.escape(v.titel || '')}"
placeholder="z. B. Sonntagsspaziergang im Stadtpark" required>
</div>
@ -690,12 +672,12 @@ window.Page_walks = (() => {
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum"
value="${_esc(v.datum || '')}" required>
value="${UI.escape(v.datum || '')}" required>
</div>
<div class="form-group">
<label class="form-label">Uhrzeit *</label>
<input class="form-control" type="time" name="uhrzeit"
value="${_esc(v.uhrzeit || '10:00')}" required>
value="${UI.escape(v.uhrzeit || '10:00')}" required>
</div>
</div>
@ -720,7 +702,7 @@ window.Page_walks = (() => {
<div id="wf-location-chip-wrap" style="${_locName ? '' : 'display:none'}">
<div class="diary-location-chip">
${UI.icon('map-pin')}
<span id="wf-location-label">${_esc(_locName || '')}</span>
<span id="wf-location-label">${UI.escape(_locName || '')}</span>
<button type="button" id="wf-location-clear" aria-label="Name entfernen">
${UI.icon('x')}
</button>
@ -742,7 +724,7 @@ window.Page_walks = (() => {
<!-- Versteckte Koordinaten-Felder -->
<input type="hidden" name="lat" id="wf-lat" value="${_locLat || ''}">
<input type="hidden" name="lon" id="wf-lon" value="${_locLon || ''}">
<input type="hidden" name="ort_name" id="wf-ort-name" value="${_esc(_locName || '')}">
<input type="hidden" name="ort_name" id="wf-ort-name" value="${UI.escape(_locName || '')}">
</div>
<div class="form-group">
@ -754,7 +736,7 @@ window.Page_walks = (() => {
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Treffpunkt-Details, Streckenlänge, Hundefreundlichkeit…">${_esc(v.beschreibung || '')}</textarea>
placeholder="Treffpunkt-Details, Streckenlänge, Hundefreundlichkeit…">${UI.escape(v.beschreibung || '')}</textarea>
</div>
</form>
@ -804,7 +786,7 @@ window.Page_walks = (() => {
document.getElementById('wf-location-suggestions').style.display = 'none';
}
_loadLeaflet().then(() => {
UI.loadLeaflet().then(() => {
setTimeout(() => {
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
_miniMap = L.map('wf-map-wrap', {
@ -895,9 +877,9 @@ window.Page_walks = (() => {
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${_esc(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
${UI.icon(_sourceIcon(s.source))}
<span>${_esc(s.name)}</span>
<span>${UI.escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {

View file

@ -276,6 +276,9 @@ const UI = (() => {
.replace(/"/g, '&quot;');
}
// Alias für ältere Aufrufe
const escHtml = escape;
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
@ -342,6 +345,567 @@ const UI = (() => {
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 2000);
}
// ----------------------------------------------------------
// LEAFLET LAZY LOADER — zentrales Laden von Leaflet + MarkerCluster
// Dedupliziert: mehrere gleichzeitige Aufrufe warten auf dasselbe Promise.
//
// Verwendung:
// await UI.loadLeaflet(); // nur Leaflet
// await UI.loadLeaflet(true); // Leaflet + MarkerCluster
// ----------------------------------------------------------
let _leafletPromise = null;
function loadLeaflet(withCluster = false) {
if (!_leafletPromise) {
_leafletPromise = new Promise((resolve, reject) => {
// CSS (Duplikat-Check)
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (window.L) { resolve(); return; }
if (document.querySelector('script[src*="leaflet.js"]')) {
// Script-Tag schon da — warten bis window.L gesetzt ist
const poll = setInterval(() => {
if (window.L) { clearInterval(poll); resolve(); }
}, 50);
return;
}
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
});
}
if (!withCluster) return _leafletPromise;
// MarkerCluster zusätzlich laden
return _leafletPromise.then(() => {
if (window.L && L.markerClusterGroup) return;
// CSS
if (!document.querySelector('link[href*="MarkerCluster"]')) {
['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(name => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = `/css/${name}`;
document.head.appendChild(link);
});
}
// JS
if (document.querySelector('script[src*="markercluster"]') ||
document.querySelector('script[src*="MarkerCluster"]')) return;
return new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js';
s.onload = resolve;
s.onerror = resolve; // graceful degradation
document.head.appendChild(s);
});
});
}
// ----------------------------------------------------------
// LEAFLET MARKER FACTORY — erzeugt einen L.divIcon-Marker
// Verwendung:
// UI.leafletMarker({ lat, lon, color, icon, size, zIndex })
// Gibt ein L.marker-Objekt zurück, das in eine Karte eingefügt werden kann.
//
// Params:
// color — CSS-Farbe (z.B. 'var(--c-primary)' oder '#22C55E')
// icon — HTML-String für das Icon (z.B. UI.icon('dog'))
// size — Durchmesser des Kreises in px (default: 32)
// label — optionaler Text der im Kreis angezeigt wird
// ----------------------------------------------------------
function leafletMarker({ lat, lon, color = 'var(--c-primary)', icon = '', size = 32, label = '' } = {}) {
const inner = label || icon;
const divIcon = L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;font-size:${Math.round(size * 0.45)}px;font-weight:700;width:${size}px;height:${size}px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);border:2px solid rgba(255,255,255,0.8)">${inner}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
return L.marker([lat, lon], { icon: divIcon });
}
// ----------------------------------------------------------
// LOCATION PICKER — zentrale Karten-Komponente
// Rendert Leaflet-Karte + GPS-Button + Ort-Chip in das Element
// mit der angegebenen containerId.
//
// Verwendung:
// const picker = UI.locationPicker({
// containerId: 'my-map-wrap',
// onSelect(lat, lon, name) { ... }
// });
// picker.setValue(lat, lon, name); // vorhandene Werte laden
// picker.getValue(); // → { lat, lon, name }
// ----------------------------------------------------------
function locationPicker({ containerId, onSelect } = {}) {
// Interne State-Variablen
let _lat = null;
let _lon = null;
let _name = null;
let _map = null;
let _marker = null;
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="36" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
function _sourceIcon(source) {
if (source === 'places') return 'star';
if (source === 'osm') return 'map-pin';
return 'map-trifold';
}
// IDs werden mit containerId geprefixt um Konflikte zu vermeiden
const p = containerId.replace(/[^a-z0-9]/gi, '-');
const ids = {
mapWrap: `${p}-map`,
chip: `${p}-chip-wrap`,
chipLabel: `${p}-chip-label`,
chipClear: `${p}-chip-clear`,
locBtn: `${p}-loc-btn`,
locBtnLabel: `${p}-loc-btn-label`,
coordsClear: `${p}-coords-clear`,
suggestions: `${p}-suggestions`,
pinHere: `${p}-pin-here`,
};
// HTML in den Container rendern
function _render(container) {
container.innerHTML = `
<div style="position:relative">
<div id="${ids.mapWrap}" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="${ids.pinHere}" style="
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
z-index:1000;background:var(--c-primary);color:#fff;border:none;
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
display:flex;align-items:center;gap:6px;white-space:nowrap">
${_svgIcon('map-pin')} Pin hier setzen
</button>
</div>
<div style="margin-top:var(--space-2)">
<div id="${ids.chip}" style="display:none">
<div class="diary-location-chip">
${_svgIcon('map-pin')}
<span id="${ids.chipLabel}"></span>
<button type="button" id="${ids.chipClear}" aria-label="Name entfernen">
${_svgIcon('x')}
</button>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger btn-sm" id="${ids.coordsClear}">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="${ids.locBtn}">
${_svgIcon('map-pin')}
<span id="${ids.locBtnLabel}">GPS POI suchen</span>
</button>
</div>
<div id="${ids.suggestions}" style="display:none;margin-top:var(--space-2)"></div>
</div>
`;
}
function _getEl(id) { return document.getElementById(id); }
function _mkIcon() {
return L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
}
function _placeMarker(lat, lon) {
if (_marker) { _marker.setLatLng([lat, lon]); return; }
_marker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_map);
_marker.on('dragend', () => {
const p2 = _marker.getLatLng();
_lat = p2.lat; _lon = p2.lng;
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
onSelect?.(_lat, _lon, _name);
});
}
function _setCoords(lat, lon) {
_lat = lat; _lon = lon;
}
function _setName(name) {
_name = name;
const chipLbl = _getEl(ids.chipLabel);
const chipWrap = _getEl(ids.chip);
const sugEl = _getEl(ids.suggestions);
if (chipLbl) chipLbl.textContent = name;
if (chipWrap) chipWrap.style.display = '';
if (sugEl) sugEl.style.display = 'none';
onSelect?.(_lat, _lon, _name);
}
function _loadLeafletLocal() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _initMap() {
_loadLeafletLocal().then(() => {
setTimeout(() => {
const mapEl = _getEl(ids.mapWrap);
if (!mapEl) return;
const lat = _lat || 48.0;
const lon = _lon || 11.9;
const zoom = _lat ? 15 : 7;
_map = L.map(ids.mapWrap, {
zoomControl: true, attributionControl: false,
dragging: true, scrollWheelZoom: false,
}).setView([lat, lon], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_map);
_map.invalidateSize();
setTimeout(() => _map?.invalidateSize(), 300);
if (_lat) _placeMarker(lat, lon);
_map.on('click', e => {
_setCoords(e.latlng.lat, e.latlng.lng);
_placeMarker(_lat, _lon);
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
onSelect?.(_lat, _lon, _name);
});
_getEl(ids.pinHere)?.addEventListener('click', () => {
const c = _map.getCenter();
_setCoords(c.lat, c.lng);
_placeMarker(c.lat, c.lng);
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
onSelect?.(_lat, _lon, _name);
});
}, 150);
});
}
function _bindEvents() {
// Chip-Name entfernen
_getEl(ids.chipClear)?.addEventListener('click', () => {
_name = null;
const chipWrap = _getEl(ids.chip);
if (chipWrap) chipWrap.style.display = 'none';
onSelect?.(_lat, _lon, null);
});
// Koordinaten + Name komplett entfernen (Zwei-Klick)
const coordsClearBtn = _getEl(ids.coordsClear);
let _clearPending = false;
coordsClearBtn?.addEventListener('click', () => {
if (!_clearPending) {
_clearPending = true;
coordsClearBtn.textContent = 'Wirklich entfernen?';
coordsClearBtn.style.color = 'var(--c-danger)';
setTimeout(() => {
_clearPending = false;
if (coordsClearBtn) {
coordsClearBtn.textContent = 'Ort entfernen';
coordsClearBtn.style.color = '';
}
}, 3000);
return;
}
_clearPending = false;
coordsClearBtn.textContent = 'Ort entfernen';
coordsClearBtn.style.color = '';
_lat = null; _lon = null; _name = null;
const chipWrap = _getEl(ids.chip);
const sugEl = _getEl(ids.suggestions);
const lbl = _getEl(ids.locBtnLabel);
if (chipWrap) chipWrap.style.display = 'none';
if (sugEl) sugEl.style.display = 'none';
if (lbl) lbl.textContent = 'GPS → POI suchen';
if (_marker) { _marker.remove(); _marker = null; }
if (_map) _map.setView([48.0, 11.9], 7);
onSelect?.(null, null, null);
});
// GPS-Button + POI-Suche
async function _showSuggestions() {
const btn = _getEl(ids.locBtn);
if (btn) setLoading(btn, true);
try {
let lat = _lat, lon = _lon;
if (lat == null || lon == null) {
const pos = await API.getLocation({ enableHighAccuracy: true });
lat = pos.lat; lon = pos.lon;
_setCoords(lat, lon);
if (_map) {
_map.setView([lat, lon], 15);
_placeMarker(lat, lon);
}
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
}
let suggestions = [];
try {
suggestions = await API.walks.nearby(lat, lon);
} catch {}
const sugEl = _getEl(ids.suggestions);
if (!sugEl) return;
if (!suggestions.length) {
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
${_svgIcon(_sourceIcon(s.source))}
<span>${escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
el.addEventListener('click', () => {
const slat = parseFloat(el.dataset.lat);
const slon = parseFloat(el.dataset.lon);
_setCoords(slat, slon);
_setName(el.dataset.name);
if (_map) {
_map.setView([slat, slon], 16);
_placeMarker(slat, slon);
}
});
});
}
sugEl.style.display = '';
onSelect?.(_lat, _lon, _name);
} catch (err) {
toast.error(err?.message?.includes('GPS') || _lat == null
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
} finally {
if (btn) setLoading(btn, false);
}
}
_getEl(ids.locBtn)?.addEventListener('click', _showSuggestions);
}
// Container initialisieren
const container = document.getElementById(containerId);
if (!container) {
console.warn('UI.locationPicker: containerId nicht gefunden:', containerId);
return { getValue: () => ({ lat: null, lon: null, name: null }), setValue: () => {} };
}
_render(container);
_bindEvents();
_initMap();
// Öffentliche API des Pickers
return {
getValue() {
return { lat: _lat, lon: _lon, name: _name };
},
setValue(lat, lon, name) {
_lat = lat != null ? parseFloat(lat) : null;
_lon = lon != null ? parseFloat(lon) : null;
_name = name || null;
// Chip aktualisieren
const chipLbl = _getEl(ids.chipLabel);
const chipWrap = _getEl(ids.chip);
const lbl = _getEl(ids.locBtnLabel);
if (chipLbl) chipLbl.textContent = _name || '';
if (chipWrap) chipWrap.style.display = _name ? '' : 'none';
if (lbl) lbl.textContent = _lat ? 'POI suchen' : 'GPS → POI suchen';
// Karte anpassen wenn bereits initialisiert
if (_map && _lat) {
_map.setView([_lat, _lon], 15);
_placeMarker(_lat, _lon);
}
},
};
}
// ----------------------------------------------------------
// RATING STARS — wiederverwendbare Bewertungskomponente
// Verwendung: UI.ratingStars({ containerId, targetType, targetId, isLoggedIn })
// Rendert Sterne-Anzeige + Inline-Widget zum Bewerten
// ----------------------------------------------------------
function ratingStars({ containerId, targetType, targetId, isLoggedIn }) {
const container = document.getElementById(containerId);
if (!container) return;
let _avgStars = 0;
let _anzahl = 0;
let _myStars = null;
let _myKommentar = '';
let _hoverStar = 0;
let _widgetOpen = false;
function _starHTML(filled, half = false, idx = 0) {
const cls = filled ? 'rating-star rating-star--filled' : (half ? 'rating-star rating-star--half' : 'rating-star rating-star--empty');
return `<span class="${cls}" data-star="${idx}" aria-label="${idx} Sterne">★</span>`;
}
function _renderAvg() {
const stars = [];
for (let i = 1; i <= 5; i++) {
const diff = _avgStars - (i - 1);
if (diff >= 1) stars.push(_starHTML(true, false, i));
else if (diff >= 0.4) stars.push(_starHTML(false, true, i));
else stars.push(_starHTML(false, false, i));
}
return stars.join('');
}
function _renderWidget() {
const stars = [];
for (let i = 1; i <= 5; i++) {
const active = (_hoverStar || _myStars || 0) >= i;
stars.push(`<span class="rating-star rating-star--pick${active ? ' rating-star--filled' : ''}" data-pick="${i}" aria-label="${i} Sterne">★</span>`);
}
return `
<div class="rating-widget" id="rw-${containerId}">
<div class="rating-pick-stars">${stars.join('')}</div>
<textarea class="form-control rating-kommentar" id="rw-komm-${containerId}"
placeholder="Kurzer Kommentar (optional, max. 200 Zeichen)"
maxlength="200" rows="2">${_myKommentar || ''}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button class="btn btn-secondary btn-sm" id="rw-cancel-${containerId}">Abbrechen</button>
<button class="btn btn-primary btn-sm" id="rw-save-${containerId}"
${!(_hoverStar || _myStars) ? 'disabled' : ''}>Speichern</button>
</div>
</div>
`;
}
function _render() {
const avgLabel = _anzahl > 0
? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})`
: 'Noch keine Bewertungen';
const rateHint = isLoggedIn
? `<button class="btn btn-ghost btn-sm rating-rate-btn" id="rw-open-${containerId}" style="font-size:var(--text-sm)">
${_myStars ? '★ Bewertung ändern' : '★ Bewerten'}
</button>`
: '';
container.innerHTML = `
<div class="rating-display">
<div class="rating-stars-avg">${_renderAvg()}</div>
<span class="rating-avg-label">${avgLabel}</span>
${rateHint}
</div>
${_widgetOpen ? _renderWidget() : ''}
`;
// Events
document.getElementById(`rw-open-${containerId}`)?.addEventListener('click', () => {
_widgetOpen = true;
_render();
_bindWidget();
});
}
function _bindWidget() {
const widget = document.getElementById(`rw-${containerId}`);
if (!widget) return;
// Hover
widget.querySelectorAll('[data-pick]').forEach(el => {
el.addEventListener('mouseenter', () => {
_hoverStar = parseInt(el.dataset.pick);
_render();
_bindWidget();
});
el.addEventListener('mouseleave', () => {
_hoverStar = 0;
_render();
_bindWidget();
});
el.addEventListener('click', () => {
_myStars = parseInt(el.dataset.pick);
_hoverStar = 0;
_render();
_bindWidget();
const saveBtn = document.getElementById(`rw-save-${containerId}`);
if (saveBtn) saveBtn.disabled = false;
});
// Touch
el.addEventListener('touchend', (e) => {
e.preventDefault();
_myStars = parseInt(el.dataset.pick);
_hoverStar = 0;
_render();
_bindWidget();
const saveBtn = document.getElementById(`rw-save-${containerId}`);
if (saveBtn) saveBtn.disabled = false;
});
});
document.getElementById(`rw-cancel-${containerId}`)?.addEventListener('click', () => {
_widgetOpen = false;
_hoverStar = 0;
_render();
});
document.getElementById(`rw-save-${containerId}`)?.addEventListener('click', async () => {
if (!_myStars) return;
const saveBtn = document.getElementById(`rw-save-${containerId}`);
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '…'; }
const komm = document.getElementById(`rw-komm-${containerId}`)?.value?.trim() || null;
try {
const res = await API.ratings.rate(targetType, targetId, _myStars, komm);
_avgStars = res.bewertung;
_anzahl = res.anz_bewertungen;
_myKommentar = komm || '';
_widgetOpen = false;
_hoverStar = 0;
_render();
toast.success('Bewertung gespeichert!');
} catch (err) {
toast.error(err?.message || 'Fehler beim Speichern.');
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
}
});
}
async function _load() {
try {
const [overview, mine] = await Promise.all([
API.ratings.list(targetType, targetId),
isLoggedIn ? API.ratings.mine(targetType, targetId) : Promise.resolve({ stars: null, kommentar: null }),
]);
_avgStars = overview.bewertung || 0;
_anzahl = overview.anz_bewertungen || 0;
_myStars = mine.stars || null;
_myKommentar = mine.kommentar || '';
} catch (e) {
// silent Bewertungen sind optional
}
_render();
}
_load();
}
// Öffentliche API
return {
toast, modal,
@ -350,8 +914,12 @@ const UI = (() => {
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
escape, help,
escape, escHtml, help,
saveToAlbum,
loadLeaflet,
leafletMarker,
locationPicker,
ratingStars,
};
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v205';
const CACHE_VERSION = 'by-v207';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten