banyaro/backend/static/js/pages/walks.js
rene ec17dfb029 Sprint 7: Gassi-Treffen — Meetup-Feature komplett
- Backend: walks.py mit allen Endpoints (CRUD, join/leave, Haversine-Filter)
- DB: walks, walk_participants, walk_participant_dogs Tabellen (bereits in database.py)
- Frontend: walks.js — Liste/Karte-Toggle, Heute/Demnächst-Gruppierung, Detail-Modal
  mit Teilnehmerliste, Beitreten/Verlassen, Erstellen/Bearbeiten-Formulare
- CSS: Walks-Komponenten (Card, Date-Badge, Spots-Anzeige, Map-View)
- api.js: walks-Abschnitt (list, get, create, update, cancel, join, leave)
- SW-Cache: by-v20 → by-v21
2026-04-14 06:12:52 +02:00

580 lines
22 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 — 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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);
}
// ----------------------------------------------------------
// 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 = `
<div class="walks-layout">
<!-- Toolbar -->
<div class="walks-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
<button class="walks-view-btn" data-view="karte">🗺️ Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
</div>
</div>
`;
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(), 50);
});
}
}
// ----------------------------------------------------------
// 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 = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐕</div>
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
Erstes Treffen planen
</button>
</div>`;
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 += `<div class="walks-section-label">🌟 Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="walks-section-label">📅 Demnächst</div>`;
html += upcoming.map(w => _walkCardHTML(w)).join('');
}
el.innerHTML = `<div class="walks-list-inner">${html}</div>`;
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 `
<div class="walks-card ${today ? 'walks-card--today' : ''}" data-id="${w.id}">
<div class="walks-card-date">
<div class="walks-card-day">${_fmtDateShort(w.datum)}</div>
<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">📍 ${_esc(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`}
</span>
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div>
</div>
<div class="walks-card-arrow"></div>
</div>`;
}
// ----------------------------------------------------------
// Leaflet + Karte
// ----------------------------------------------------------
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;
}
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 => {
const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer;
const color = _isToday(w.datum) ? '#C4843A' : (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)">🐕</div>`,
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);
});
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _openDetail(walkId) {
let walk;
try {
walk = await API.walks.get(walkId);
} 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 teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
const body = `
<div class="walks-detail-header">
<div class="walks-detail-date">
${_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)">📍 ${_esc(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`}
</span>
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
</div>
</div>
${walk.beschreibung ? `
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
` : ''}
<div class="walks-detail-section">
<div class="walks-detail-section-label">Teilnehmer</div>
${teilnehmerHTML}
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
</p>
`;
let footer;
if (isOwn) {
footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk" style="color:var(--c-danger)">Stornieren</button>
<button type="button" class="btn btn-secondary flex-1" id="wd-edit">Bearbeiten</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`;
} else if (!_appState.user) {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-login">Anmelden zum Beitreten</button>
`;
} else if (isJoined) {
footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave" style="color:var(--c-danger)">Nicht mehr teilnehmen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`;
} else if (isPast || isFull) {
footer = `<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>`;
} else {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
`;
}
UI.modal.open({ title: `🐕 ${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);
});
document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Treffen stornieren?',
message: 'Alle Teilnehmer werden benachrichtigt. Nicht rückgängig.',
confirmText: 'Stornieren', danger: true,
});
if (!ok) return;
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);
});
document.getElementById('wd-leave')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Nicht mehr teilnehmen?',
message: `Du verlässt „${walk.titel}".`,
confirmText: 'Austreten',
});
if (!ok) return;
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); }
});
}
// ----------------------------------------------------------
// Beitreten-Formular (Hunde wählen)
// ----------------------------------------------------------
function _showJoinForm(walk) {
const dogs = _appState.dogs || [];
const dogsHtml = dogs.length
? dogs.map(d => `
<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>
🐕 ${_esc(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 ? `📍 ${_esc(walk.ort_name)}` : ''}
</p>
<div class="form-group">
<label class="form-label">Mit welchen Hunden?</label>
${dogsHtml}
</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
`;
UI.modal.open({ title: `Treffen beitreten`, body, footer });
document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('join-confirm')?.addEventListener('click', async () => {
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
// ----------------------------------------------------------
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;
const body = `
<form id="walk-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
value="${_esc(v.titel || '')}"
placeholder="z. B. Sonntagsspaziergang im Stadtpark" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum"
value="${_esc(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>
</div>
</div>
<div class="form-group">
<label class="form-label">Treffpunkt</label>
<div style="display:flex;gap:var(--space-2)">
<input class="form-control" type="text" name="ort_name"
value="${_esc(v.ort_name || '')}"
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
style="flex:1">
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
</div>
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
</small>
</div>
<div class="form-group">
<label class="form-label">Max. Teilnehmer</label>
<input class="form-control" type="number" name="max_teilnehmer"
value="${v.max_teilnehmer || 10}" min="2" max="50">
</div>
<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>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : '📅 Treffen planen'}
</button>
`;
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 Treffen planen', body, footer });
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('walk-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('walk-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ enableHighAccuracy: true });
_userPos = pos;
document.getElementById('walk-lat').value = pos.lat;
document.getElementById('walk-lon').value = pos.lon;
document.getElementById('walk-gps-hint').textContent = '✅ Standort ermittelt';
} catch { UI.toast.error('GPS nicht verfügbar.'); }
UI.setLoading(btn, false);
});
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);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln (📍).');
return;
}
await UI.asyncButton(btn, async () => {
const payload = {
titel: fd.titel?.trim(),
datum: fd.datum,
uhrzeit: fd.uhrzeit,
lat: parseFloat(fd.lat),
lon: parseFloat(fd.lon),
ort_name: fd.ort_name || 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 });
// Beim eigenen neuen Treffen gleich beitreten?
// Nein — Veranstalter ist automatisch dabei (für Teilnehmer-Sicht)
UI.toast.success('Treffen geplant! 🎉');
}
UI.modal.close();
_renderList();
_renderMarkers();
});
});
}
return { init, refresh, onDogChange, openNew };
})();