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:
parent
066b722c5e
commit
e98ce0d232
9 changed files with 761 additions and 471 deletions
|
|
@ -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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,'&').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: '<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,
|
||||
|
|
|
|||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,'>').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 = (() => {
|
|||
<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 => {
|
||||
|
|
|
|||
|
|
@ -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: `<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,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue