banyaro/backend/static/js/pages/walks.js
rene c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00

1639 lines
69 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 = (() => {
// ----------------------------------------------------------
// OFFLINE-CACHE
// ----------------------------------------------------------
const _CACHE_KEY = 'by_walks_cache';
const _PENDING_KEY = 'by_walks_pending';
function _getPending() {
try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
}
function _setPending(list) {
try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
}
function _addPending(data) {
const list = _getPending();
const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
created_at: new Date().toISOString(),
teilnehmer_count: 1, max_teilnehmer: data.max_teilnehmer || 10,
status: 'open' };
list.push(entry);
_setPending(list);
return entry;
}
async function _syncPending() {
if (!navigator.onLine) return;
const list = _getPending();
if (!list.length) return;
let ok = 0;
for (const item of [...list]) {
try {
const { id: _pid, _isPending, created_at: _ca, teilnehmer_count: _tc, status: _st, ...payload } = item;
await API.walks.create(payload);
_setPending(_getPending().filter(x => x.id !== item.id));
ok++;
} catch {}
}
if (ok > 0) { UI.toast.success(`${ok} Treffen synchronisiert.`); _loadData(); }
}
window.addEventListener('online', _syncPending);
let _container = null;
let _appState = null;
let _data = [];
let _view = 'liste'; // 'liste' | 'karte'
let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm'
let _map = null;
let _markers = [];
let _userPos = null;
let _challengeData = null;
let _gassiZeiten = [];
// _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." — 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');
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();
// Desktop: Leaflet sofort laden damit Karte bereit ist wenn Daten kommen
if (window.innerWidth >= 1024) UI.loadLeaflet();
try { _userPos = await API.getLocation(); } catch {}
_loadData();
}
function refresh() {
_loadData();
if (_tab === 'challenge') _loadChallenge();
if (_tab === 'stamm') _loadGassiZeiten();
}
function onDogChange() {}
function openNew() {
if (_tab === 'challenge') { _showSubmitForm(); return; }
if (_tab === 'stamm') { _showGassiZeitForm(); return; }
_showCreateForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="walks-layout">
<!-- Tab-Bar -->
<div class="by-tabs" id="walks-tab-bar" style="padding:var(--space-3) var(--space-4) 0">
<button class="by-tab active" data-tab="treffen">${UI.icon('paw-print')} Treffen</button>
<button class="by-tab" data-tab="challenge">${UI.icon('camera')} Challenge</button>
<button class="by-tab" data-tab="stamm">${UI.icon('clock')} Stamm-Gassis</button>
</div>
<!-- Tab: Treffen -->
<div id="walks-tab-treffen" class="walks-tab-panel">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} 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 (auf Desktop immer sichtbar via CSS) -->
<div id="walks-map-view" class="walks-content"
style="${window.innerWidth >= 1024 ? '' : 'display:none'}">
<div id="walks-map" class="walks-map"></div>
</div>
</div>
<!-- Tab: Challenge -->
<div id="walks-tab-challenge" class="walks-tab-panel" style="display:none">
<div id="challenge-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
</div>
</div>
<!-- Tab: Stamm-Gassis -->
<div id="walks-tab-stamm" class="walks-tab-panel" style="display:none">
<div class="by-toolbar">
<span style="font-weight:600;color:var(--c-text)">${UI.icon('clock')} Stamm-Gassi-Zeiten</span>
<button class="btn btn-primary btn-sm" id="gassi-zeit-add-btn">${UI.icon('plus')} Meine Zeit eintragen</button>
</div>
<div id="gassi-zeiten-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
</div>
</div>
</div>
`;
// Tab-Bar Events
document.getElementById('walks-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
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();
});
document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; }
_showGassiZeitForm();
});
}
function _switchTab(tab) {
_tab = tab;
document.querySelectorAll('.by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none');
const panel = document.getElementById(`walks-tab-${tab}`);
if (panel) panel.style.display = '';
if (tab === 'challenge' && !_challengeData) _loadChallenge();
if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten();
}
function _switchView(view) {
_view = view;
document.querySelectorAll('.walks-view-btn').forEach(b =>
b.classList.toggle('active', b.dataset.view === view));
// Desktop: beide Panels bleiben via CSS sichtbar
if (window.innerWidth >= 1024) return;
document.getElementById('walks-list-view').style.display = view === 'liste' ? '' : 'none';
document.getElementById('walks-map-view').style.display = view === 'karte' ? '' : 'none';
if (view === 'karte') {
UI.loadLeaflet().then(() => {
_initMap();
setTimeout(() => _map?.invalidateSize(), 150);
setTimeout(() => _map?.invalidateSize(), 400);
});
}
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
const pending = _getPending();
try {
const fetched = await API.walks.list(
_userPos?.lat ?? null,
_userPos?.lon ?? null
);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
_data = [...pending, ...fetched];
_renderList();
_renderMarkers();
if (window.innerWidth >= 1024) {
UI.loadLeaflet().then(() => {
_initMap();
setTimeout(() => _map?.invalidateSize(), 150);
setTimeout(() => _map?.invalidateSize(), 400);
});
}
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_data = [...pending, ...(JSON.parse(raw).data || [])];
_renderList();
_renderMarkers();
UI.toast.info('Offline — zeige zuletzt geladene Treffen.');
return;
}
} catch {}
_data = pending;
if (pending.length) { _renderList(); _renderMarkers(); return; }
UI.toast.error('Treffen konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// 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)">${UI.icon('dog')}</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="by-section-label">${UI.icon('star')} Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="by-section-label">${UI.icon('calendar-dots')} 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)));
});
el.querySelectorAll('.wk-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_openNoteModal(
'walk',
parseInt(btn.dataset.wkNoteId),
btn.dataset.wkNoteLabel,
btn.dataset.wkNoteOrt || null
);
});
});
}
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">${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`}
</span>
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
${w._isPending ? `<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600">⏳ Sync ausstehend</span>` : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
<div class="walks-card-arrow"></div>
${_appState.user ? `<button class="btn-icon wk-note-btn"
data-wk-note-id="${w.id}"
data-wk-note-label="${UI.escape(w.titel + ' ' + w.datum)}"
data-wk-note-ort="${UI.escape(w.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>`;
}
// ----------------------------------------------------------
// Leaflet + Karte
// ----------------------------------------------------------
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 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));
_markers.push(m);
});
// Karte auf alle Marker zoomen damit alle Treffen sichtbar sind
if (_markers.length === 1) {
_map.setView(_markers[0].getLatLng(), 13);
} else if (_markers.length > 1) {
const group = L.featureGroup(_markers);
_map.fitBounds(group.getBounds().pad(0.2));
}
}
// ----------------------------------------------------------
// RSVP-Status → Label + Farbe
// ----------------------------------------------------------
function _rsvpBadge(status) {
if (status === 'yes') return `<span class="walks-rsvp-badge walks-rsvp--yes">Zusage</span>`;
if (status === 'maybe') return `<span class="walks-rsvp-badge walks-rsvp--maybe">Vielleicht</span>`;
if (status === 'no') return `<span class="walks-rsvp-badge walks-rsvp--no">Absage</span>`;
return `<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>`;
}
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 `
<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">${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>`;
}
// ----------------------------------------------------------
// 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 mit Hundefotos
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => {
const dogsHTML = (t.hunde_liste || []).map(d => {
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
style="width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;border:1.5px solid var(--c-border)">`
: `<div style="width:28px;height:28px;border-radius:50%;background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1.5px solid var(--c-border)">
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
</div>`;
return `<div style="display:flex;align-items:center;gap:4px">
${av}
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}</span>
</div>`;
}).join('');
return `
<div class="walks-participant">
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<div style="flex:1;min-width:0">
<div class="walks-participant-name">${UI.escape(t.user_name)}</div>
${dogsHTML ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:4px">${dogsHTML}</div>` : ''}
</div>
</div>`;
}).join('')
: '';
// Einladungsliste
const invListHTML = invitations.length
? invitations.map(inv => _invitationRowHTML(inv)).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Einladungen.</p>`;
// RSVP-Section für eingeladene Nutzer
const rsvpSectionHTML = (isInvited && !isOwn) ? `
<div class="walks-rsvp-section" id="wd-rsvp-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Deine Antwort</div>
<div class="walks-rsvp-buttons">
<button type="button" class="walks-rsvp-btn ${myRsvp === 'yes' ? 'active' : ''}" data-rsvp="yes">
${UI.icon('check')} Zusagen
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp="maybe">
${UI.icon('question')} Vielleicht
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'no' ? 'active' : ''}" data-rsvp="no">
${UI.icon('x')} Absagen
</button>
</div>
</div>
` : '';
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)">${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`}
</span>
<span class="walks-badge">${UI.icon('paw-print')} ${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)">${UI.escape(walk.beschreibung)}</p>
` : ''}
${rsvpSectionHTML}
<div class="walks-detail-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('users')} Einladungen</div>
${isOwn && !isPast ? `<button type="button" class="btn btn-secondary btn-sm" id="wd-invite-btn">${UI.icon('user-plus')} Einladen</button>` : ''}
</div>
<div id="wd-inv-list">${invListHTML}</div>
</div>
${walk.teilnehmer?.length ? `
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Beigetreten (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML}
</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>
<!-- Fotos nach dem Treffen -->
<div class="walks-detail-section" id="wd-photos-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('images')} Fotos</div>
${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? `
<label style="cursor:pointer">
<input type="file" id="wd-photo-input" accept="image/*" style="display:none">
<span class="btn btn-secondary btn-sm">${UI.icon('camera')} Foto hinzufügen</span>
</label>` : ''}
</div>
<div id="wd-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:var(--space-2)">
${(walk.photos || []).length === 0
? `<p style="grid-column:1/-1;color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos.</p>`
: (walk.photos || []).map(p => `
<div style="position:relative;aspect-ratio:1">
<img src="${UI.escape(p.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
${p.user_id === _appState.user?.id || isOwn ? `
<button type="button" class="wd-photo-del" data-photo-id="${p.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0">✕</button>
` : ''}
</div>`).join('')}
</div>
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
</p>
${isOwn && !isPast ? `
<div id="wd-cancel-wrap" style="margin-top:var(--space-3)">
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk"
style="color:var(--c-danger);width:100%">
${UI.icon('x-circle')} Treffen stornieren
</button>
</div>` : ''}
${isJoined && !isOwn ? `
<div id="wd-leave-wrap" style="margin-top:var(--space-3)">
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave"
style="color:var(--c-danger);width:100%">
${UI.icon('sign-out')} Nicht mehr teilnehmen
</button>
</div>` : ''}
`;
let footer;
if (isOwn) {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-edit">${UI.icon('pencil-simple')} 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-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">${UI.icon('dog')} Mitmachen</button>
`;
}
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);
// Foto-Upload
document.getElementById('wd-photo-input')?.addEventListener('change', async function() {
if (!this.files.length) return;
const file = this.files[0];
// Client-Side-Kompression vor Upload (HEIC bleibt unverändert)
const toUpload = await API.compressImage(file);
const formData = new FormData();
formData.append('file', toUpload);
try {
const photo = await API.walks.uploadPhoto(walk.id, formData);
const grid = document.getElementById('wd-photos-grid');
if (grid) {
grid.querySelector('p')?.remove();
const div = document.createElement('div');
div.style.cssText = 'position:relative;aspect-ratio:1';
div.innerHTML = `
<img src="${UI.escape(photo.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
<button type="button" class="wd-photo-del" data-photo-id="${photo.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0">✕</button>`;
grid.appendChild(div);
_bindPhotoDel(walk.id, div);
UI.toast.success('Foto hochgeladen.');
}
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
this.value = '';
});
// Foto löschen — alle bestehenden Buttons
function _bindPhotoDel(walkId, container) {
container.querySelectorAll('.wd-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Foto löschen?')) return;
try {
await API.walks.deletePhoto(walkId, parseInt(btn.dataset.photoId));
btn.closest('[style*="aspect-ratio"]')?.remove();
UI.toast.success('Foto gelöscht.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}
_bindPhotoDel(walk.id, document);
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 => `
<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">${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>
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Alle Freunde wurden bereits eingeladen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr
${walk.ort_name ? `· ${UI.escape(walk.ort_name)}` : ''}
</p>
<div id="invite-list">${listHTML}</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="invite-back">Zurück</button>
`;
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 = `
<div class="walks-inv-avatar">${_avatarInitials(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.`);
});
});
});
}
// ----------------------------------------------------------
// 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>
${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')} ${UI.escape(walk.ort_name)}` : ''}
</p>
<form id="join-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Mit welchen Hunden?</label>
${dogsHtml}
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="submit" form="join-form" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button>
`;
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 = '<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>';
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="${UI.escape(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="${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="${UI.escape(v.uhrzeit || '10:00')}" required>
</div>
</div>
<div class="form-group" id="wf-location-group">
<label class="form-label">Treffpunkt</label>
<!-- Mini-Karte -->
<div style="position:relative">
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="wf-map-pin-here" 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">
${UI.icon('map-pin')} Pin hier setzen
</button>
</div>
<!-- Ort-Chip -->
<div style="margin-top:var(--space-2)">
<div id="wf-location-chip-wrap" style="${_locName ? '' : 'display:none'}">
<div class="diary-location-chip">
${UI.icon('map-pin')}
<span id="wf-location-label">${UI.escape(_locName || '')}</span>
<button type="button" id="wf-location-clear" aria-label="Name entfernen">
${UI.icon('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="wf-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="wf-location-btn">
${UI.icon('map-pin')}
<span id="wf-location-btn-label">${_locLat ? 'POI suchen' : 'GPS → POI suchen'}</span>
</button>
</div>
<!-- Vorschläge -->
<div id="wf-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
</div>
<!-- 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="${UI.escape(_locName || '')}">
</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…">${UI.escape(v.beschreibung || '')}</textarea>
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="walk-form" class="btn btn-primary" style="width:100%">
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('calendar-dots')} Treffen planen`}
</button>
<button type="button" class="btn btn-secondary" id="wf-cancel">Abbrechen</button>
</div>
`;
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';
}
UI.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 = '<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="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
${UI.icon(_sourceIcon(s.source))}
<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 => {
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.');
UI.modal.close();
_renderList();
_renderMarkers();
} else {
let created;
try {
created = await API.walks.create(payload);
} catch (netErr) {
if (netErr instanceof TypeError || !navigator.onLine) {
_addPending(payload);
UI.modal.close();
UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.');
_loadData();
return;
}
throw netErr;
}
if (created?._queued) { UI.modal.close(); _loadData(); return; }
_data.unshift({ ...created, teilnehmer_count: 0 });
UI.toast.success('Treffen geplant! 🎉');
UI.modal.close();
_renderList();
_renderMarkers();
}
});
});
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
<button id="wk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="wk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Gassi-Treffen…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="wk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="wk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#wk-note-close')?.addEventListener('click', close);
ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#wk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
// ==============================================================
// FEATURE 1: Foto-Challenge der Woche
// ==============================================================
async function _loadChallenge() {
const el = document.getElementById('challenge-content');
if (!el) return;
try {
_challengeData = await API.get('challenges/current');
_renderChallenge();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Challenge nicht laden.</p>`;
}
}
function _renderChallenge() {
const el = document.getElementById('challenge-content');
if (!el || !_challengeData) return;
const { challenge, submissions, my_submission_id, days_left } = _challengeData;
const canSubmit = _appState.user && !my_submission_id;
const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`;
el.innerHTML = `
<div class="challenge-banner">
<div class="challenge-banner-inner">
<div class="challenge-thema">${UI.escape(challenge.thema)}</div>
<div class="challenge-meta">
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} ${_fmtDate(challenge.end_date)}
&nbsp;·&nbsp; ${UI.icon('timer')} Noch ${dayLabel}
</div>
${canSubmit ? `<button class="btn btn-primary btn-sm" id="challenge-submit-btn" style="margin-top:var(--space-3)">${UI.icon('camera')} Foto einreichen</button>` : ''}
${my_submission_id ? `<span class="badge badge-success" style="margin-top:var(--space-2)">${UI.icon('check')} Du hast bereits teilgenommen</span>` : ''}
</div>
</div>
<div class="challenge-winners" id="challenge-winners-section">
<h4 style="padding:var(--space-3) var(--space-4);margin:0;color:var(--c-text-secondary);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.05em">Letzte Gewinner</h4>
<div id="challenge-winners-list"><p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Lädt…</p></div>
</div>
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h4 style="margin:0;font-size:var(--text-sm);font-weight:600;color:var(--c-text)">
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
</h4>
</div>
<div class="challenge-gallery" id="challenge-gallery">
${submissions.length === 0
? `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8);grid-column:1/-1">Noch keine Fotos — sei der Erste! 📸</p>`
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
</div>
`;
// Submit-Button
const submitBtn = document.getElementById('challenge-submit-btn');
if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm);
// Vote-Buttons
el.querySelectorAll('.challenge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
const subId = parseInt(btn.dataset.id);
try {
const res = await API.post(`challenges/submissions/${subId}/vote`, {});
btn.querySelector('.vote-count').textContent = res.votes;
btn.classList.toggle('voted', res.voted);
} catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); }
});
});
// Gewinner laden
_loadChallengeWinners();
}
function _challengeSubmissionCard(s) {
const voted = s.i_voted;
return `
<div class="challenge-sub-card">
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
onerror="this.src='/icons/icon-192.png'"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
<div class="challenge-sub-info">
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
${s.caption ? `<div class="challenge-sub-caption">${UI.escape(s.caption)}</div>` : ''}
<button class="challenge-vote-btn ${voted ? 'voted' : ''}" data-id="${s.id}">
${UI.icon(voted ? 'heart-fill' : 'heart')} <span class="vote-count">${s.votes}</span>
</button>
</div>
</div>
`;
}
async function _loadChallengeWinners() {
const el = document.getElementById('challenge-winners-list');
if (!el) return;
try {
const winners = await API.get('challenges/winners');
if (!winners.length) { el.innerHTML = '<p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Noch keine vergangenen Challenges.</p>'; return; }
el.innerHTML = `<div class="challenge-winners-row">` +
winners.map(w => {
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
return `<div class="challenge-winner-chip">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
<div>
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(w.winner.user_name)} · ${w.winner.votes} ❤️</div>
</div>
</div>`;
}).join('') +
`</div>`;
} catch {}
}
async function _showSubmitForm() {
if (!_challengeData) return;
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
UI.modal.open({
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
body: `
<form id="challenge-submit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label>Foto *</label>
<input type="file" id="challenge-foto-input" accept="image/*" required style="width:100%">
</div>
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="challenge-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Bildunterschrift</label>
<input type="text" id="challenge-caption" placeholder="z.B. Mein Bello beim besten Schnüffeln…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
`,
});
document.getElementById('challenge-submit-ok').addEventListener('click', async () => {
const fotoInput = document.getElementById('challenge-foto-input');
if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; }
const caption = document.getElementById('challenge-caption')?.value?.trim() || '';
const dogSelect = document.getElementById('challenge-dog-select');
const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : '';
const fd = new FormData();
fd.append('foto', fotoInput.files[0]);
if (caption) fd.append('caption', caption);
if (dogId) fd.append('dog_id', dogId);
try {
await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd);
UI.toast.success('Foto eingereicht! Viel Erfolg 🎉');
UI.modal.close();
_challengeData = null;
_loadChallenge();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); }
});
}
// ==============================================================
// FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis)
// ==============================================================
const _WOCHENTAGE = [
{ key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' },
{ key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' },
{ key: 'so', label: 'So' },
];
async function _loadGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
try {
const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : '';
_gassiZeiten = await API.get(`gassi-zeiten${params}`);
_renderGassiZeiten();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Gassi-Zeiten nicht laden.</p>`;
}
}
function _renderGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
if (!_gassiZeiten.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('clock')}
<p>Noch keine Stamm-Gassi-Zeiten in deiner Nähe.</p>
<p style="font-size:var(--text-sm)">Trag deine regelmäßigen Zeiten ein — andere finden dich dann!</p>
</div>`;
return;
}
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
let html = '';
if (myZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">Meine Zeiten</div>`;
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
}
if (andereZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">In deiner Nähe</div>`;
html += andereZeiten.map(z => _gassiZeitCard(z)).join('');
}
el.innerHTML = html;
// Events
el.querySelectorAll('.gz-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Gassi-Zeit löschen?')) return;
try {
await API.del(`gassi-zeiten/${btn.dataset.id}`);
_gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id));
_renderGassiZeiten();
UI.toast.success('Gelöscht.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id));
if (!gz) return;
try {
const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 });
const idx = _gassiZeiten.findIndex(z => z.id === gz.id);
if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv };
_renderGassiZeiten();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
const userId = parseInt(btn.dataset.userId);
App.navigate('chat', { user_id: userId });
});
});
}
function _gassiZeitCard(z) {
const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase();
const distLabel = z.distance_m != null
? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}</span>`
: '';
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
return `
<div class="gassi-zeit-card" style="${pausedStyle}">
<div class="gz-avatar">
${z.dog_foto_url
? `<img src="${UI.escape(z.dog_foto_url)}" alt="${UI.escape(z.dog_name || '')}">`
: `<div class="gz-avatar-placeholder">${UI.icon('paw-print')}</div>`}
</div>
<div class="gz-body">
<div class="gz-name">${UI.escape(z.dog_name || z.user_name || '?')}
${z.dog_rasse ? `<span class="badge" style="font-size:var(--text-xs)">${UI.escape(z.dog_rasse)}</span>` : ''}
${!z.aktiv ? `<span class="badge badge-warning">Pausiert</span>` : ''}
</div>
<div class="gz-meta">
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
&nbsp;·&nbsp; ${wochentageLabel}
${z.ort_name ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
${distLabel}
</div>
${z.notiz ? `<div class="gz-notiz">${UI.escape(z.notiz)}</div>` : ''}
</div>
<div class="gz-actions">
${z.is_mine ? `
<button class="btn btn-outline btn-xs gz-toggle-btn" data-id="${z.id}" title="${z.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(z.aktiv ? 'pause' : 'play')}
</button>
<button class="btn btn-outline btn-xs gz-delete-btn" data-id="${z.id}" title="Löschen">
${UI.icon('trash')}
</button>
` : `
<button class="btn btn-primary btn-xs gz-chat-btn" data-user-id="${z.user_id}" title="Chat öffnen">
${UI.icon('chat-circle')} Mitmachen
</button>
`}
</div>
</div>
`;
}
async function _showGassiZeitForm() {
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
const wdBtns = _WOCHENTAGE.map(w =>
`<label class="wd-btn"><input type="checkbox" value="${w.key}"> ${w.label}</label>`
).join('');
UI.modal.open({
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
body: `
<form id="gassi-zeit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="gz-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="gz-uhrzeit" required style="width:100%">
</div>
<div class="form-group">
<label>Wochentage *</label>
<div class="wd-selector">${wdBtns}</div>
</div>
<div class="form-group">
<label>Ort (optional)</label>
<input type="text" id="gz-ort-name" placeholder="z.B. Stadtpark Ebersberg" style="width:100%">
</div>
<div class="form-group">
<label>Notiz (optional)</label>
<input type="text" id="gz-notiz" placeholder="z.B. Wir sind eine ruhige Gruppe…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
`,
});
document.getElementById('gz-save-btn').addEventListener('click', async () => {
const uhrzeit = document.getElementById('gz-uhrzeit')?.value;
if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; }
const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value);
if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; }
const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null;
const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null;
const notiz = document.getElementById('gz-notiz')?.value?.trim() || null;
const payload = { wochentage, uhrzeit, ort_name: ortName, notiz };
if (dogId) payload.dog_id = dogId;
if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; }
try {
const created = await API.post('gassi-zeiten', payload);
_gassiZeiten.unshift({ ...created });
_renderGassiZeiten();
UI.toast.success('Gassi-Zeit eingetragen! 🐾');
UI.modal.close();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();