diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 48c3e58..e36649b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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 = (() => { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index fa11127..f61e741 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -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 - ? `${_escape(dog.name)}` + ? `${UI.escape(dog.name)}` : `${UI.icon('dog')}`; return `
${av}
-
${_escape(dog.name)}
- ${dog.rasse ? `
${_escape(dog.rasse)}
` : ''} +
${UI.escape(dog.name)}
+ ${dog.rasse ? `
${UI.escape(dog.rasse)}
` : ''}
`; }).join(''); @@ -260,20 +240,6 @@ window.Page_diary = (() => { UI.setLoading(btn, false); } - // ---------------------------------------------------------- - // EMPTY-STATE HELPER - // ---------------------------------------------------------- - function _emptyState(icon, title, text, cta = '') { - return `
- -
${title}
- ${text ? `

${text}

` : ''} - ${cta ? `
${cta}
` : ''} -
`; - } - // ---------------------------------------------------------- // 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.', - `` - ); + 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: ``, + }); listEl.querySelector('#diary-first-entry') ?.addEventListener('click', () => _showForm(null)); return; @@ -339,11 +305,11 @@ window.Page_diary = (() => { : ''; const locationHtml = e.location_name - ? `

${_escape(e.location_name)}

` + ? `

${UI.escape(e.location_name)}

` : ''; const textPreview = e.text - ? `

${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}

` + ? `

${UI.escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}

` : ''; // Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein') @@ -363,7 +329,7 @@ window.Page_diary = (() => { ${typ.icon} ${typ.label} ${dateStr} - ${e.titel ? `
${_escape(e.titel)}
` : ''} + ${e.titel ? `
${UI.escape(e.titel)}
` : ''} ${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 `
- ${dog.foto_url ? `` : `${UI.icon('dog')}`} + return `
+ ${dog.foto_url ? `` : `${UI.icon('dog')}`}
`; }).join(''); return `
${avatars}
`; @@ -408,9 +374,9 @@ window.Page_diary = (() => { const dog = _appState.dogs.find(d => d.id === did); return dog ? `
- ${dog.foto_url ? `` : `${UI.icon('dog')}`} + ${dog.foto_url ? `` : `${UI.icon('dog')}`}
- ${_escape(dog.name)} + ${UI.escape(dog.name)}
` : ''; }).join('')}
` @@ -428,11 +394,11 @@ window.Page_diary = (() => { ${entry.location_name ? `
- ${entry.gps_lat ? `${_escape(entry.location_name)}` : _escape(entry.location_name)} + ${entry.gps_lat ? `${UI.escape(entry.location_name)}` : UI.escape(entry.location_name)}
` : ''} ${dogsHtml} ${entry.text - ? `

${_escape(entry.text)}

` + ? `

${UI.escape(entry.text)}

` : ''} ${tags.length ? `
@@ -486,9 +452,9 @@ window.Page_diary = (() => {
- ${d.foto_url ? `` : `${UI.icon('dog')}`} + ${d.foto_url ? `` : `${UI.icon('dog')}`}
- ${_escape(d.name)} + ${UI.escape(d.name)} `).join('')}
` : ''; @@ -507,12 +473,12 @@ window.Page_diary = (() => {
+ value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
+ placeholder="Was ist passiert? Besonderheiten, Gedanken…">${UI.escape(entry?.text || '')}
@@ -532,7 +498,7 @@ window.Page_diary = (() => {
- ${_escape(entry?.location_name || '')} + ${UI.escape(entry?.location_name || '')} @@ -549,7 +515,7 @@ window.Page_diary = (() => {
${dogPickerHtml} -
+
`).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, '&').replace(//g, '>') - .replace(/"/g, '"'); - } - // ---------------------------------------------------------- // IMPORT // ---------------------------------------------------------- @@ -998,7 +958,7 @@ window.Page_diary = (() => { body: `

Importiere Einträge aus einer anderen App in das Tagebuch von - ${_escape(_appState.activeDog?.name || 'deinem Hund')}. + ${UI.escape(_appState.activeDog?.name || 'deinem Hund')}.

@@ -1076,7 +1036,7 @@ window.Page_diary = (() => { const errHtml = res.errors?.length ? `
${res.errors.length} Fehler anzeigen -
${_escape(res.errors.join('\n'))}
` +
${UI.escape(res.errors.join('\n'))}
` : ''; resultEl.innerHTML = ` @@ -1100,7 +1060,7 @@ window.Page_diary = (() => { resultEl.innerHTML = `
- Fehler: ${_escape(e.message || String(e))} + Fehler: ${UI.escape(e.message || String(e))}
`; resultEl.style.display = 'block'; UI.setLoading(btn, false); diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 41cd76f..59c3ecc 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -48,16 +48,7 @@ window.Page_events = (() => { return ``; } - function _emptyState(icon, title, text, cta = '') { - return `
- -
${title}
- ${text ? `

${text}

` : ''} - ${cta ? `
${cta}
` : ''} -
`; - } + // _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: `
${typ.icon}
`, @@ -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 = (() => {
-
-
- - -
-
- - -
+
+ +
-
@@ -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 { diff --git a/backend/static/js/pages/places.js b/backend/static/js/pages/places.js index 4c0d9e2..e9c29b7 100644 --- a/backend/static/js/pages/places.js +++ b/backend/static/js/pages/places.js @@ -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: '', label: 'Hundeschule', color: '#8B5CF6' }, }; - function _esc(s) { - return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - } + // _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: '', color: '#6B7280' }; - const icon = L.divIcon({ - className: '', - html: `
${t.icon}
`, - iconSize: [34, 34], - iconAnchor: [17, 17], - }); - const marker = L.marker([place.lat, place.lon], { icon }) + const t = TYPEN[place.typ] || { icon: '', 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 = (() => {
${t.icon}
-
${_esc(p.name)}
+
${UI.escape(p.name)}
${t.label} - ${p.adresse ? `· ${_esc(p.adresse)}` : ''} + ${p.adresse ? `· ${UI.escape(p.adresse)}` : ''}
${flags.length ? `
${flags.map(f => `${f}`).join('')}
` : ''}
@@ -281,16 +248,17 @@ window.Page_places = (() => {
${t.icon}
-
${_esc(place.name)}
+
${UI.escape(place.name)}
${t.label}
- ${place.adresse ? `

${UI.icon('map-pin')} ${_esc(place.adresse)}

` : ''} - ${place.telefon ? `

${UI.icon('phone')} ${_esc(place.telefon)}

` : ''} - ${place.website ? `

${UI.icon('arrow-square-out')} ${_esc(place.website)}

` : ''} + ${place.adresse ? `

${UI.icon('map-pin')} ${UI.escape(place.adresse)}

` : ''} + ${place.telefon ? `

${UI.icon('phone')} ${UI.escape(place.telefon)}

` : ''} + ${place.website ? `

${UI.icon('arrow-square-out')} ${UI.escape(place.website)}

` : ''} ${flags.length ? `
${flags.map(f => `${f}`).join('')}
` : ''} +

- Eingetragen von ${_esc(place.user_name || 'Unbekannt')} + Eingetragen von ${UI.escape(place.user_name || 'Unbekannt')}

`; @@ -301,7 +269,14 @@ window.Page_places = (() => { `; - 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 = (() => {
+ value="${UI.escape(place?.name || '')}" placeholder="z. B. Café Hund & Herrchen" required>
@@ -341,38 +316,25 @@ window.Page_places = (() => {
-
- - - -
- - - - ${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'} - +
+ value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
+ value="${UI.escape(place?.website || '')}" placeholder="https://…">
+ value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
@@ -406,7 +368,7 @@ window.Page_places = (() => {
`; - UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : ' Neuer Ort', body, footer }); + UI.modal.open({ title: isEdit ? `${UI.escape(place.name)} bearbeiten` : ' 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, diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index af0b351..bb3ce04 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -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 : `
${typ.icon}
`, - 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(` ${typ.icon} ${typ.label}
- ${r.beschreibung ? _escape(r.beschreibung.slice(0, 80)) + '
' : ''} + ${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '
' : ''} 📍 ${distStr} entfernt
📅 ${_fmtDate(r.created_at)} ${r.bestaetigt ? '
✅ Bestätigt' : ''} @@ -316,7 +274,7 @@ window.Page_poison = (() => { ${r.beschreibung ? `

- ${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''} + ${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}

` : ''}
@@ -357,7 +315,7 @@ window.Page_poison = (() => {
${r.beschreibung - ? `

${_escape(r.beschreibung)}

` + ? `

${UI.escape(r.beschreibung)}

` : ''}
@@ -482,21 +440,7 @@ window.Page_poison = (() => {
-
- - - -
- - - - ${_userPos - ? '✅ Aktueller Standort vorausgefüllt' - : 'GPS-Button drücken um Standort zu ermitteln'} - +
@@ -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, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index fad314e..4e64b4d 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -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,'&').replace(//g,'>').replace(/"/g,'"'); - } - - function _emptyState(icon, title, text, cta = '') { - return `
- -
${title}
- ${text ? `

${text}

` : ''} - ${cta ? `
${cta}
` : ''} -
`; - } + // _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 = `${_esc(route.name)}${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`; + const tip = `${UI.escape(route.name)}${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 = - `

Fehler: ${_esc(err.message)}

`; + `

Fehler: ${UI.escape(err.message)}

`; } } @@ -478,12 +448,12 @@ window.Page_routes = (() => {
`; } 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.', - `` - ); + 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: ``, + }); 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 - ? `` + ? `` : `
`; const authorLine = isDiscover - ? `
${UI.icon('user')} ${_esc(r.user_name||'Anonym')}
` + ? `
${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}
` : ''; return ` @@ -544,7 +514,7 @@ window.Page_routes = (() => {
${previewContent}
${authorLine} -
${_esc(r.name)}
+
${UI.escape(r.name)}
${dist ? `${UI.icon('map-trifold')} ${dist}` : ''} ${dur ? `${UI.icon('timer')} ${dur}` : ''} @@ -560,7 +530,7 @@ window.Page_routes = (() => { @@ -661,7 +631,7 @@ window.Page_routes = (() => { const photoGallery = photos.length ? ` - ${route.beschreibung ? `

${_esc(route.beschreibung)}

` : ''} + ${route.beschreibung ? `

${UI.escape(route.beschreibung)}

` : ''}
Lädt Orte entlang der Route…
+

- ${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')}

`; @@ -707,7 +677,14 @@ window.Page_routes = (() => { `; - 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 = (() => {
${UI.icon('map-pin')} Entlang der Route
${Object.values(byType).map(group => `
-
${group.icon} ${_esc(group.label)} (${group.items.length})
+
${group.icon} ${UI.escape(group.label)} (${group.items.length})
${group.items.slice(0, 5).map(p => `
- ${_esc(p.name || group.label)} - ${p.opening_hours ? `${UI.icon('clock')} ${_esc(p.opening_hours)}` : ''} - ${p.phone ? `${UI.icon('phone')} ${_esc(p.phone)}` : ''} + ${UI.escape(p.name || group.label)} + ${p.opening_hours ? `${UI.icon('clock')} ${UI.escape(p.opening_hours)}` : ''} + ${p.phone ? `${UI.icon('phone')} ${UI.escape(p.phone)}` : ''}
`).join('')} ${group.items.length > 5 ? `
+${group.items.length-5} weitere
` : ''} @@ -898,7 +875,7 @@ window.Page_routes = (() => { const pts = track.map(p => ` `).join('\n'); const gpx = ` - ${_esc(route.name)}\n${pts}\n + ${UI.escape(route.name)}\n${pts}\n `; const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const url = URL.createObjectURL(blob); @@ -1093,7 +1070,7 @@ window.Page_routes = (() => {
- +
@@ -1225,15 +1202,15 @@ window.Page_routes = (() => { const friendRows = friends.map(f => { const initial = (f.name || '?')[0].toUpperCase(); - return `
${_esc(initial)}
- ${_esc(f.name || 'Anonym')} + font-weight:600;flex-shrink:0">${UI.escape(initial)}
+ ${UI.escape(f.name || 'Anonym')}
`; }).join(''); diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index c041668..9f6088d 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -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,'&').replace(//g,'>').replace(/"/g,'"'); - } + // _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 = (() => {
${w.uhrzeit}
-
${_esc(w.titel)}
- ${w.ort_name ? `
${UI.icon('map-pin')} ${_esc(w.ort_name)}
` : ''} +
${UI.escape(w.titel)}
+ ${w.ort_name ? `
${UI.icon('map-pin')} ${UI.escape(w.ort_name)}
` : ''}
${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: `
${UI.icon('dog')}
`, - 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 = (() => {
${_avatarInitials(inv.user_name)}
-
${_esc(inv.user_name)}
- ${inv.hunde ? `
${UI.icon('dog')} ${_esc(inv.hunde)}
` : ''} +
${UI.escape(inv.user_name)}
+ ${inv.hunde ? `
${UI.icon('dog')} ${UI.escape(inv.hunde)}
` : ''}
${_rsvpBadge(inv.status)}
`; @@ -328,8 +297,8 @@ window.Page_walks = (() => { ? walk.teilnehmer.map(t => `
${_avatarInitials(t.user_name)}
- ${_esc(t.user_name)} - ${t.hunde ? `${UI.icon('dog')} ${_esc(t.hunde)}` : ''} + ${UI.escape(t.user_name)} + ${t.hunde ? `${UI.icon('dog')} ${UI.escape(t.hunde)}` : ''}
`).join('') : ''; @@ -362,7 +331,7 @@ window.Page_walks = (() => { ${_fmtDate(walk.datum)}
um ${walk.uhrzeit} Uhr
- ${walk.ort_name ? `
${UI.icon('map-pin')} ${_esc(walk.ort_name)}
` : ''} + ${walk.ort_name ? `
${UI.icon('map-pin')} ${UI.escape(walk.ort_name)}
` : ''}
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`} @@ -373,7 +342,7 @@ window.Page_walks = (() => {
${walk.beschreibung ? ` -

${_esc(walk.beschreibung)}

+

${UI.escape(walk.beschreibung)}

` : ''} ${rsvpSectionHTML} @@ -393,8 +362,13 @@ window.Page_walks = (() => {
` : ''} +
+ +
+
+

- Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')} + Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}

${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 => ` -
+
${_avatarInitials(f.friend_name)}
-
${_esc(f.friend_name)}
+
${UI.escape(f.friend_name)}
@@ -565,7 +547,7 @@ window.Page_walks = (() => { const body = `

${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr - ${walk.ort_name ? `· ${_esc(walk.ort_name)}` : ''} + ${walk.ort_name ? `· ${UI.escape(walk.ort_name)}` : ''}

${listHTML}
`; @@ -590,7 +572,7 @@ window.Page_walks = (() => { await API.walks.invite(walk.id, friendId); row.innerHTML = `
${_avatarInitials(name)}
-
${_esc(name)}
+
${UI.escape(name)}
Eingeladen `; UI.toast.success(`${name} eingeladen.`); @@ -609,14 +591,14 @@ window.Page_walks = (() => { `).join('') : `

Keine Hunde im Profil — du kannst trotzdem mitmachen.

`; const body = `

${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr
- ${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''} + ${walk.ort_name ? `${UI.icon('map-pin')} ${UI.escape(walk.ort_name)}` : ''}

@@ -682,7 +664,7 @@ window.Page_walks = (() => {
@@ -690,12 +672,12 @@ window.Page_walks = (() => {
+ value="${UI.escape(v.datum || '')}" required>
+ value="${UI.escape(v.uhrzeit || '10:00')}" required>
@@ -720,7 +702,7 @@ window.Page_walks = (() => {
${UI.icon('map-pin')} - ${_esc(_locName || '')} + ${UI.escape(_locName || '')} @@ -742,7 +724,7 @@ window.Page_walks = (() => { - +
@@ -754,7 +736,7 @@ window.Page_walks = (() => {
+ placeholder="Treffpunkt-Details, Streckenlänge, Hundefreundlichkeit…">${UI.escape(v.beschreibung || '')}
@@ -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 => ` `).join(''); sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 2d8c1df..1d6194a 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -276,6 +276,9 @@ const UI = (() => { .replace(/"/g, '"'); } + // 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: `
${inner}
`, + 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 = ''; + + 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 = ` +
+
+ +
+
+ +
+ + +
+ +
+ `; + } + + 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 = '

Keine Orte in der Nähe gefunden.

'; + } else { + sugEl.innerHTML = suggestions.map(s => ` + `).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 ``; + } + + 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(``); + } + return ` +
+
${stars.join('')}
+ +
+ + +
+
+ `; + } + + function _render() { + const avgLabel = _anzahl > 0 + ? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})` + : 'Noch keine Bewertungen'; + + const rateHint = isLoggedIn + ? `` + : ''; + + container.innerHTML = ` +
+
${_renderAvg()}
+ ${avgLabel} + ${rateHint} +
+ ${_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, }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 43b128a..160f469 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -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