banyaro/backend/static/js/pages/places.js
rene b9df636535 Sprint 6: Karte / Orte / Routen mit GPS-Aufzeichnung
- backend/routes/places.py: CRUD für hundefreundliche Orte (6 Typen)
- backend/routes/routen.py: CRUD für Gassi-Routen mit GPS-Track (JSON)
- main.py: beide Router eingehängt (/api/places, /api/routes)
- api.js: places + routes erweitert (list, update, delete)
- pages/places.js: Karte + Liste, Typ-Filter, Ort anlegen/bearbeiten
- pages/routes.js: Routen entdecken + GPS-Aufzeichnung mit Stoppuhr
- pages/map.js: zentrale Übersichtskarte (Orte + Giftköder, Layer-Toggle)
- components.css: Styles für alle drei neuen Seiten
- sw.js: by-v19 → by-v20
2026-04-14 06:03:37 +02:00

482 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Orte (Hundefreundliche Orte)
Karte + Liste, Eigene Orte anlegen/bearbeiten
============================================================ */
window.Page_places = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _data = [];
let _activeTyp = null; // null = alle
let _leafletLoaded = false;
let _userPos = null;
// ----------------------------------------------------------
// Typen-Konfiguration
// ----------------------------------------------------------
const TYPEN = {
restaurant: { icon: '🍽️', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '🐕', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '🧻', label: 'Kotbeutel-Station', color: '#6B7280' },
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' },
};
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadData();
try { _userPos = await API.getLocation(); } catch {}
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="places-layout">
<!-- Toolbar -->
<div class="places-toolbar">
<div class="places-filter" id="places-filter">
<button class="places-filter-btn active" data-typ="">🗺️ Alle</button>
${Object.entries(TYPEN).map(([k, t]) =>
`<button class="places-filter-btn" data-typ="${k}">${t.icon} ${t.label}</button>`
).join('')}
</div>
<button class="btn btn-primary btn-sm" id="places-add-btn" style="white-space:nowrap">
+ Ort hinzufügen
</button>
</div>
<!-- Karte -->
<div id="places-map" class="places-map"></div>
<!-- Liste -->
<div id="places-list" class="places-list">
<div class="places-list-inner">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
Lädt…
</p>
</div>
</div>
</div>
`;
// Events
document.getElementById('places-filter').addEventListener('click', e => {
const btn = e.target.closest('.places-filter-btn');
if (!btn) return;
document.querySelectorAll('.places-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeTyp = btn.dataset.typ || null;
_applyFilter();
});
document.getElementById('places-add-btn').addEventListener('click', () => {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden.');
App.navigate('settings');
return;
}
_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;
}
// ----------------------------------------------------------
// Karte initialisieren
// ----------------------------------------------------------
function _initMap() {
const el = document.getElementById('places-map');
if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
const zoom = _userPos ? 13 : 6;
_map = L.map('places-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_map);
// GPS-Locate-Button
L.Control.Locate = L.Control.extend({
onAdd() {
const btn = L.DomUtil.create('button', 'places-locate-btn');
btn.innerHTML = '📍';
btn.title = 'Meinen Standort';
btn.onclick = async () => {
try {
const pos = await API.getLocation({ enableHighAccuracy: true });
_userPos = pos;
_map.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.error('Standort konnte nicht ermittelt werden.'); }
};
return btn;
},
onRemove() {},
});
new L.Control.Locate({ position: 'bottomright' }).addTo(_map);
_renderMarkers();
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
try {
_data = await API.places.list();
_renderList();
_renderMarkers();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden der Orte.');
}
}
// ----------------------------------------------------------
// Filter anwenden
// ----------------------------------------------------------
function _filtered() {
return _activeTyp ? _data.filter(p => p.typ === _activeTyp) : _data;
}
function _applyFilter() {
_renderList();
_renderMarkers();
}
// ----------------------------------------------------------
// Marker rendern
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map || !window.L) return;
_markers.forEach(m => m.remove());
_markers = [];
_filtered().forEach(place => {
const t = TYPEN[place.typ] || { icon: '📍', 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 })
.addTo(_map)
.on('click', () => _openDetail(place));
_markers.push(marker);
});
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const list = document.getElementById('places-list');
if (!list) return;
const items = _filtered();
if (!items.length) {
list.innerHTML = `
<div class="places-list-inner">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
${_activeTyp ? 'Keine Orte in dieser Kategorie.' : 'Noch keine Orte eingetragen.'}
</p>
</div>`;
return;
}
list.innerHTML = `
<div class="places-list-inner">
${items.map(p => _cardHTML(p)).join('')}
</div>`;
list.querySelectorAll('.places-card').forEach(card => {
const id = parseInt(card.dataset.id);
const place = _data.find(p => p.id === id);
if (place) card.addEventListener('click', () => _openDetail(place));
});
}
function _cardHTML(p) {
const t = TYPEN[p.typ] || { icon: '📍', label: p.typ, color: '#6B7280' };
const flags = [
p.hund_rein === true ? '🐕 Hund rein' : null,
p.leine_pflicht === true ? '🔗 Leinenpflicht' : null,
p.wasser_fuer_hunde === true ? '💧 Wasser' : null,
].filter(Boolean);
return `
<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-meta">
<span class="places-card-typ" style="color:${t.color}">${t.label}</span>
${p.adresse ? `· <span>${_esc(p.adresse)}</span>` : ''}
</div>
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
</div>
<div class="places-card-arrow"></div>
</div>`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
function _openDetail(place) {
const t = TYPEN[place.typ] || { icon: '📍', label: place.typ, color: '#6B7280' };
const isOwn = _appState.user?.id === place.user_id;
const flags = [
place.hund_rein === true ? '🐕 Hund erlaubt' : (place.hund_rein === false ? '🚫 Kein Hund' : null),
place.leine_pflicht === true ? '🔗 Leinenpflicht' : (place.leine_pflicht === false ? '✅ Leine optional' : null),
place.wasser_fuer_hunde === true ? '💧 Wasser vorhanden': null,
].filter(Boolean);
const body = `
<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="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)">📍 ${_esc(place.adresse)}</p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">🌐 ${_esc(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>` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Eingetragen von ${_esc(place.user_name || 'Unbekannt')}
</p>
`;
const footer = isOwn ? `
<button type="button" class="btn btn-secondary flex-1" id="place-detail-close">Schließen</button>
<button type="button" class="btn btn-ghost btn-sm" id="place-detail-delete" style="color:var(--c-danger)">Löschen</button>
<button type="button" class="btn btn-primary flex-1" id="place-detail-edit">Bearbeiten</button>
` : `
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
`;
UI.modal.open({ title: `${t.icon} ${place.name}`, body, footer });
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
document.getElementById('place-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_showForm(place);
});
document.getElementById('place-detail-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Ort löschen?',
message: `${place.name}" wird dauerhaft entfernt.`,
confirmText: 'Löschen',
danger: true,
});
if (!ok) return;
try {
await API.places.delete(place.id);
_data = _data.filter(p => p.id !== place.id);
UI.modal.close();
_renderList();
_renderMarkers();
UI.toast.success('Ort gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
// Auf Karte zentrieren
if (_map) _map.setView([place.lat, place.lon], 15);
}
// ----------------------------------------------------------
// Formular — Ort anlegen / bearbeiten
// ----------------------------------------------------------
function _showForm(place) {
const isEdit = !!place;
const typOpts = Object.entries(TYPEN)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.icon} ${t.label}</option>`)
.join('');
const body = `
<form id="place-form" autocomplete="off">
<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>
</div>
<div class="form-group">
<label class="form-label">Kategorie *</label>
<select class="form-control" name="typ">${typOpts}</select>
</div>
<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">📍</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>
<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">
</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://…">
</div>
<div class="form-group" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
🐕 Hund darf rein
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_pflicht" ${place?.leine_pflicht ? 'checked' : ''}>
🔗 Leinenpflicht beachten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="wasser_fuer_hunde" ${place?.wasser_fuer_hunde ? 'checked' : ''}>
💧 Wasser für Hunde vorhanden
</label>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
<button type="submit" form="place-form" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button>
`;
UI.modal.open({ title: isEdit ? `${place.name} bearbeiten` : '📍 Neuer Ort', body, footer });
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
// 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);
});
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);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln.');
return;
}
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name?.trim(),
typ: fd.typ,
lat: parseFloat(fd.lat),
lon: parseFloat(fd.lon),
adresse: fd.adresse || null,
website: fd.website || null,
hund_rein: 'hund_rein' in fd,
leine_pflicht: 'leine_pflicht' in fd,
wasser_fuer_hunde: 'wasser_fuer_hunde' in fd,
};
if (isEdit) {
const updated = await API.places.update(place.id, payload);
const idx = _data.findIndex(p => p.id === place.id);
if (idx !== -1) _data[idx] = updated;
UI.toast.success('Gespeichert.');
} else {
const created = await API.places.create(payload);
_data.unshift(created);
UI.toast.success('Ort hinzugefügt!');
}
UI.modal.close();
_renderList();
_renderMarkers();
});
});
}
return { init, refresh, onDogChange };
})();