/* ============================================================ BAN YARO — Gassi-Treffen Treffen entdecken, erstellen, beitreten ============================================================ */ window.Page_walks = (() => { let _container = null; let _appState = null; let _data = []; 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,'"'); } // Datum deutsch formatieren: "2026-04-20" → "Sonntag, 20. April 2026" 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." function _fmtDateShort(iso) { if (!iso) return '—'; const d = new Date(iso + 'T12:00:00'); return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' }); } function _isToday(iso) { return iso === new Date().toISOString().slice(0, 10); } function _isPast(iso) { return iso < new Date().toISOString().slice(0, 10); } function _sourceIcon(source) { if (source === 'places') return 'star'; if (source === 'osm') return 'map-pin'; return 'map-trifold'; } // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; _render(); try { _userPos = await API.getLocation(); } catch {} _loadData(); } function refresh() { _loadData(); } function onDogChange() {} function openNew() { _showCreateForm(); } // ---------------------------------------------------------- // RENDER — Grundstruktur // ---------------------------------------------------------- function _render() { _container.innerHTML = `

Lädt…

`; document.getElementById('walks-view-toggle').addEventListener('click', e => { const btn = e.target.closest('.walks-view-btn'); if (!btn) return; _switchView(btn.dataset.view); }); document.getElementById('walks-create-btn').addEventListener('click', () => { if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; } _showCreateForm(); }); } function _switchView(view) { _view = view; document.querySelectorAll('.walks-view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); document.getElementById('walks-list-view').style.display = view === 'liste' ? '' : 'none'; document.getElementById('walks-map-view').style.display = view === 'karte' ? '' : 'none'; if (view === 'karte') { _loadLeaflet().then(() => { _initMap(); setTimeout(() => _map?.invalidateSize(), 150); setTimeout(() => _map?.invalidateSize(), 400); }); } } // ---------------------------------------------------------- // Daten laden // ---------------------------------------------------------- async function _loadData() { try { _data = await API.walks.list( _userPos?.lat ?? null, _userPos?.lon ?? null ); _renderList(); _renderMarkers(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Laden.'); } } // ---------------------------------------------------------- // Liste rendern // ---------------------------------------------------------- function _renderList() { const el = document.getElementById('walks-list'); if (!el) return; if (!_data.length) { el.innerHTML = `
${UI.icon('dog')}

Noch keine Treffen in deiner Nähe.

`; document.getElementById('walks-first-btn')?.addEventListener('click', _showCreateForm); return; } // Heute + zukünftige Treffen const heute = _data.filter(w => _isToday(w.datum)); const upcoming = _data.filter(w => !_isToday(w.datum) && !_isPast(w.datum)); let html = ''; if (heute.length) { html += `
${UI.icon('star')} Heute
`; html += heute.map(w => _walkCardHTML(w)).join(''); } if (upcoming.length) { html += `
${UI.icon('calendar-dots')} Demnächst
`; html += upcoming.map(w => _walkCardHTML(w)).join(''); } el.innerHTML = `
${html}
`; el.querySelectorAll('.walks-card').forEach(card => { card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); }); } function _walkCardHTML(w) { const isOwn = _appState.user?.id === w.user_id; const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; const today = _isToday(w.datum); const spots = w.max_teilnehmer - w.teilnehmer_count; return `
${_fmtDateShort(w.datum)}
${w.uhrzeit}
${_esc(w.titel)}
${w.ort_name ? `
${UI.icon('map-pin')} ${_esc(w.ort_name)}
` : ''}
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`} ${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer} ${isOwn ? 'Mein Treffen' : ''}
`; } // ---------------------------------------------------------- // 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; const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; _map = L.map('walks-map', { zoomControl: true, attributionControl: false }) .setView(center, _userPos ? 12 : 6); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); _renderMarkers(); } function _renderMarkers() { if (!_map || !window.L) return; _markers.forEach(m => m.remove()); _markers = []; _data.forEach(w => { if (!w.lat || !w.lon) return; const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E'); const icon = L.divIcon({ className: '', html: `
${UI.icon('dog')}
`, iconSize: [32, 32], iconAnchor: [16, 16], }); const m = L.marker([w.lat, w.lon], { icon }) .addTo(_map) .bindTooltip(`${w.titel} · ${_fmtDateShort(w.datum)} ${w.uhrzeit}`, { direction: 'top', offset: [0,-16] }) .on('click', () => _openDetail(w.id)); _markers.push(m); }); } // ---------------------------------------------------------- // RSVP-Status → Label + Farbe // ---------------------------------------------------------- function _rsvpBadge(status) { if (status === 'yes') return `Zusage`; if (status === 'maybe') return `Vielleicht`; if (status === 'no') return `Absage`; return `Eingeladen`; } function _avatarInitials(name) { const parts = (name || '?').trim().split(/\s+/); const initials = parts.length >= 2 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2); return initials.toUpperCase(); } function _invitationRowHTML(inv) { return `
${_avatarInitials(inv.user_name)}
${_esc(inv.user_name)}
${inv.hunde ? `
${UI.icon('dog')} ${_esc(inv.hunde)}
` : ''}
${_rsvpBadge(inv.status)}
`; } // ---------------------------------------------------------- // Detail-Modal // ---------------------------------------------------------- async function _openDetail(walkId) { let walk, participantData; try { walk = await API.walks.get(walkId); if (_appState.user) { try { participantData = await API.walks.participants(walkId); } catch {} } } catch (err) { UI.toast.error(err.message); return; } const isOwn = _appState.user?.id === walk.user_id; const isJoined = walk.teilnehmer?.some(t => t.user_id === _appState.user?.id); const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer; const isPast = _isPast(walk.datum); const spots = walk.max_teilnehmer - walk.teilnehmer_count; const myRsvp = participantData?.my_rsvp ?? null; const isInvited = !!myRsvp; const invitations = participantData?.invitations ?? []; // Teilnehmerliste (join-Teilnehmer, klassisch) const teilnehmerHTML = walk.teilnehmer?.length ? walk.teilnehmer.map(t => `
${_avatarInitials(t.user_name)}
${_esc(t.user_name)} ${t.hunde ? `${UI.icon('dog')} ${_esc(t.hunde)}` : ''}
`).join('') : ''; // Einladungsliste const invListHTML = invitations.length ? invitations.map(inv => _invitationRowHTML(inv)).join('') : `

Noch keine Einladungen.

`; // RSVP-Section für eingeladene Nutzer const rsvpSectionHTML = (isInvited && !isOwn) ? `
${UI.icon('check-circle')} Deine Antwort
` : ''; const body = `
${_fmtDate(walk.datum)}
um ${walk.uhrzeit} Uhr
${walk.ort_name ? `
${UI.icon('map-pin')} ${_esc(walk.ort_name)}
` : ''}
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`} ${UI.icon('paw-print')} ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer ${isOwn ? 'Dein Treffen' : ''}
${walk.beschreibung ? `

${_esc(walk.beschreibung)}

` : ''} ${rsvpSectionHTML}
${isOwn && !isPast ? `` : ''}
${invListHTML}
${walk.teilnehmer?.length ? `
${UI.icon('check-circle')} Beigetreten (${walk.teilnehmer_count}/${walk.max_teilnehmer})
${teilnehmerHTML}
` : ''}

Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}

${isOwn && !isPast ? `
` : ''} ${isJoined && !isOwn ? `
` : ''} `; let footer; if (isOwn) { footer = ` `; } else if (!_appState.user) { footer = ` `; } else if (isJoined) { footer = ` `; } else if (isPast || isFull) { footer = ``; } else { footer = ` `; } UI.modal.open({ title: `${UI.icon('dog')} ${walk.titel}`, body, footer }); document.getElementById('wd-close')?.addEventListener('click', UI.modal.close); document.getElementById('wd-login')?.addEventListener('click', () => { UI.modal.close(); App.navigate('settings'); }); document.getElementById('wd-edit')?.addEventListener('click', () => { UI.modal.close(); _showEditForm(walk); }); // Einladen-Button document.getElementById('wd-invite-btn')?.addEventListener('click', () => { UI.modal.close(); _showInviteModal(walk); }); // RSVP-Buttons document.querySelectorAll('.walks-rsvp-btn').forEach(btn => { btn.addEventListener('click', async () => { const status = btn.dataset.rsvp; try { await API.walks.rsvp(walk.id, status); // Buttons aktualisieren document.querySelectorAll('.walks-rsvp-btn').forEach(b => { b.classList.toggle('active', b.dataset.rsvp === status); }); UI.toast.success( status === 'yes' ? 'Zugesagt!' : status === 'maybe' ? 'Antwort: Vielleicht.' : 'Abgesagt.' ); } catch (err) { UI.toast.error(err.message); } }); }); // Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal) let _cancelPending = false; document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => { const btn = document.getElementById('wd-cancel-walk'); if (!_cancelPending) { _cancelPending = true; btn.textContent = 'Wirklich stornieren? (nochmal tippen)'; btn.style.fontWeight = 'var(--weight-semibold)'; setTimeout(() => { _cancelPending = false; if (btn) { btn.innerHTML = `${UI.icon('x-circle')} Treffen stornieren`; btn.style.fontWeight = ''; } }, 3000); return; } _cancelPending = false; try { await API.walks.cancel(walk.id); _data = _data.filter(w => w.id !== walk.id); UI.modal.close(); _renderList(); _renderMarkers(); UI.toast.success('Treffen storniert.'); } catch (err) { UI.toast.error(err.message); } }); document.getElementById('wd-join')?.addEventListener('click', () => { UI.modal.close(); _showJoinForm(walk); }); // Austreten: Zwei-Klick-Pattern let _leavePending = false; document.getElementById('wd-leave')?.addEventListener('click', async () => { const btn = document.getElementById('wd-leave'); if (!_leavePending) { _leavePending = true; btn.textContent = 'Wirklich austreten? (nochmal tippen)'; btn.style.fontWeight = 'var(--weight-semibold)'; setTimeout(() => { _leavePending = false; if (btn) { btn.innerHTML = `${UI.icon('sign-out')} Nicht mehr teilnehmen`; btn.style.fontWeight = ''; } }, 3000); return; } _leavePending = false; try { const res = await API.walks.leave(walk.id); const idx = _data.findIndex(w => w.id === walk.id); if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count; UI.modal.close(); _renderList(); UI.toast.success('Du nimmst nicht mehr teil.'); } catch (err) { UI.toast.error(err.message); } }); } // ---------------------------------------------------------- // Freunde einladen // ---------------------------------------------------------- async function _showInviteModal(walk) { let candidates; try { candidates = await API.walks.inviteCandidates(walk.id); } catch (err) { UI.toast.error(err.message); return; } const listHTML = candidates.length ? candidates.map(f => `
${_avatarInitials(f.friend_name)}
${_esc(f.friend_name)}
`).join('') : `

Alle Freunde wurden bereits eingeladen.

`; const body = `

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

${listHTML}
`; const footer = ` `; UI.modal.open({ title: `${UI.icon('user-plus')} Freunde einladen`, body, footer }); document.getElementById('invite-back')?.addEventListener('click', () => { UI.modal.close(); _openDetail(walk.id); }); document.querySelectorAll('.walks-invite-send').forEach(btn => { btn.addEventListener('click', async () => { const row = btn.closest('.walks-invite-row'); const friendId = parseInt(row.dataset.friendId); const name = row.dataset.friendName; await UI.asyncButton(btn, async () => { await API.walks.invite(walk.id, friendId); row.innerHTML = `
${_avatarInitials(name)}
${_esc(name)}
Eingeladen `; UI.toast.success(`${name} eingeladen.`); }); }); }); } // ---------------------------------------------------------- // Beitreten-Formular (Hunde wählen) // ---------------------------------------------------------- function _showJoinForm(walk) { const dogs = _appState.dogs || []; const dogsHtml = dogs.length ? dogs.map(d => ` `).join('') : `

Keine Hunde im Profil — du kannst trotzdem mitmachen.

`; const body = `

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

${dogsHtml}
`; const footer = ` `; UI.modal.open({ title: `Treffen beitreten`, body, footer }); document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('join-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.getElementById('join-confirm'); const checked = [...document.querySelectorAll('[name="dog"]:checked')]; const dogIds = checked.map(cb => parseInt(cb.value)); await UI.asyncButton(btn, async () => { const res = await API.walks.join(walk.id, dogIds); const idx = _data.findIndex(w => w.id === walk.id); if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count; UI.modal.close(); _renderList(); _renderMarkers(); UI.toast.success(`Du nimmst teil! 🎉`); }); }); } // ---------------------------------------------------------- // Treffen erstellen / bearbeiten — Formular // ---------------------------------------------------------- function _showCreateForm(prefill = {}) { const today = new Date().toISOString().slice(0, 10); _showWalkForm(null, { datum: today, uhrzeit: '10:00', ...prefill }); } function _showEditForm(walk) { _showWalkForm(walk); } function _showWalkForm(walk, defaults = {}) { const isEdit = !!walk; const v = walk || defaults; // Location-State (verwaltet außerhalb des DOM) let _locLat = v.lat != null ? parseFloat(v.lat) : null; let _locLon = v.lon != null ? parseFloat(v.lon) : null; let _locName = v.ort_name || null; const _pinSvg = ''; const body = `
${UI.icon('map-pin')} ${_esc(_locName || '')}
`; const footer = `
`; UI.modal.open({ title: isEdit ? `${UI.icon('pencil-simple')} Treffen bearbeiten` : `${UI.icon('dog')} Treffen planen`, body, footer }); document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close); // --- Mini-Karte --- let _miniMap = null, _miniMarker = null, _mapEditing = false; const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] }); function _placeMarker(lat, lon) { if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } _miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap); _miniMarker.on('dragend', () => { const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng; document.getElementById('wf-lat').value = _locLat; document.getElementById('wf-lon').value = _locLon; document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; }); } function _setCoords(lat, lon) { _locLat = lat; _locLon = lon; document.getElementById('wf-lat').value = lat; document.getElementById('wf-lon').value = lon; } function _setName(name) { _locName = name; document.getElementById('wf-location-label').textContent = name; document.getElementById('wf-location-chip-wrap').style.display = ''; document.getElementById('wf-ort-name').value = name; document.getElementById('wf-location-suggestions').style.display = 'none'; } _loadLeaflet().then(() => { setTimeout(() => { const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7; _miniMap = L.map('wf-map-wrap', { 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(_miniMap); _miniMap.invalidateSize(); if (_locLat) _placeMarker(lat, lon); _miniMap.on('click', e => { _setCoords(e.latlng.lat, e.latlng.lng); _placeMarker(_locLat, _locLon); document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; }); document.getElementById('wf-map-pin-here')?.addEventListener('click', () => { const c = _miniMap.getCenter(); _setCoords(c.lat, c.lng); _placeMarker(c.lat, c.lng); document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; }); }, 150); }); // Ort-Name-Chip entfernen document.getElementById('wf-location-clear')?.addEventListener('click', () => { _locName = null; document.getElementById('wf-location-chip-wrap').style.display = 'none'; document.getElementById('wf-ort-name').value = ''; }); // Koordinaten + Name entfernen (Zwei-Klick) const clearBtn = document.getElementById('wf-coords-clear'); let _clearPending = false; clearBtn?.addEventListener('click', () => { if (!_clearPending) { _clearPending = true; clearBtn.textContent = 'Wirklich entfernen?'; clearBtn.style.color = 'var(--c-danger)'; setTimeout(() => { _clearPending = false; if (clearBtn) { clearBtn.textContent = 'Ort entfernen'; clearBtn.style.color = ''; } }, 3000); return; } _clearPending = false; clearBtn.textContent = 'Ort entfernen'; clearBtn.style.color = ''; _locLat = null; _locLon = null; _locName = null; document.getElementById('wf-lat').value = ''; document.getElementById('wf-lon').value = ''; document.getElementById('wf-ort-name').value = ''; document.getElementById('wf-location-chip-wrap').style.display = 'none'; document.getElementById('wf-location-suggestions').style.display = 'none'; document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen'; if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); } }); // GPS → POI-Suche (wie diary.js) async function _showSuggestions() { const btn = document.getElementById('wf-location-btn'); UI.setLoading(btn, true); try { let lat = _locLat, lon = _locLon; if (lat == null || lon == null) { const pos = await API.getLocation({ enableHighAccuracy: true }); lat = pos.lat; lon = pos.lon; _setCoords(lat, lon); if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); if (_miniMarker) _miniMarker.dragging.disable(); } document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; } const suggestions = _appState.user ? await API.walks.nearby(lat, lon) : []; const sugEl = document.getElementById('wf-location-suggestions'); if (!suggestions.length) { sugEl.innerHTML = '

Keine Orte in der Nähe gefunden.

'; } else { sugEl.innerHTML = suggestions.map(s => ` `).join(''); sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { el.addEventListener('click', () => { const slat = parseFloat(el.dataset.lat); const slon = parseFloat(el.dataset.lon); _setCoords(slat, slon); _setName(el.dataset.name); if (_miniMap) { _miniMap.setView([slat, slon], 16); _placeMarker(slat, slon); if (_miniMarker) _miniMarker.dragging.disable(); } }); }); } sugEl.style.display = ''; } catch (err) { UI.toast.error(err?.message?.includes('GPS') || _locLat == null ? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.'); } finally { UI.setLoading(btn, false); } } document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions); // Formular absenden document.getElementById('walk-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); // Koordinaten aus State lesen (nicht aus fd, da hidden) const lat = _locLat; const lon = _locLon; if (!lat || !lon) { UI.toast.warning('Bitte einen Treffpunkt auf der Karte wählen oder GPS nutzen.'); return; } await UI.asyncButton(btn, async () => { const payload = { titel: fd.titel?.trim(), datum: fd.datum, uhrzeit: fd.uhrzeit, lat: parseFloat(lat), lon: parseFloat(lon), ort_name: _locName || null, max_teilnehmer: parseInt(fd.max_teilnehmer) || 10, beschreibung: fd.beschreibung || null, }; if (isEdit) { const updated = await API.walks.update(walk.id, payload); const idx = _data.findIndex(w => w.id === walk.id); if (idx !== -1) _data[idx] = { ..._data[idx], ...updated }; UI.toast.success('Treffen aktualisiert.'); } else { const created = await API.walks.create(payload); _data.unshift({ ...created, teilnehmer_count: 0 }); UI.toast.success('Treffen geplant! 🎉'); } UI.modal.close(); _renderList(); _renderMarkers(); }); }); } return { init, refresh, onDogChange, openNew }; })();