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
This commit is contained in:
parent
b9df636535
commit
ec17dfb029
6 changed files with 977 additions and 2 deletions
|
|
@ -190,6 +190,23 @@ const API = (() => {
|
|||
delete(id) { return del(`/routes/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GASSI-TREFFEN
|
||||
// ----------------------------------------------------------
|
||||
const walks = {
|
||||
list(lat = null, lon = null, radius = 20000) {
|
||||
const params = new URLSearchParams({ radius });
|
||||
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
|
||||
return get(`/walks?${params}`);
|
||||
},
|
||||
get(id) { return get(`/walks/${id}`); },
|
||||
create(data) { return post('/walks', data); },
|
||||
update(id, data) { return patch(`/walks/${id}`, data); },
|
||||
cancel(id) { return del(`/walks/${id}`); },
|
||||
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
|
||||
leave(id) { return del(`/walks/${id}/join`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// WETTER
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -267,7 +284,7 @@ const API = (() => {
|
|||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, weather, push,
|
||||
places, routes, walks, weather, push,
|
||||
subscribeToPush, getLocation,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
580
backend/static/js/pages/walks.js
Normal file
580
backend/static/js/pages/walks.js
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
/* ============================================================
|
||||
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,'>').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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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 };
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue