banyaro/backend/static/js/pages/walks.js

1402 lines
57 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 hidden">
<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 hidden">
<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') {
_initMap().then(() => {
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) {
_initMap().then(() => {
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 class="text-secondary">Noch keine Treffen in deiner Nähe.</p>
<button class="btn btn-primary mt-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();
UI.noteModal(
'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
// ----------------------------------------------------------
async function _initMap() {
const el = document.getElementById('walks-map');
if (!el || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
_map = await UI.map.create('walks-map', { center, zoom: _userPos ? 12 : 6 });
_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 class="text-xs-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 class="flex-1-min">
<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 class="text-sm-muted">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/*" class="hidden">
<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" class="mt-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" class="mt-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 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 class="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 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 class="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 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 class="grid-2">
<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>
<div id="wf-location-picker"></div>
</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 class="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 w-full">
${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);
// Location Picker
let _wfPicker = null;
setTimeout(() => {
_wfPicker = UI.locationPicker({
containerId: 'wf-location-picker',
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
});
if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName);
}, 50);
// 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)
// ----------------------------------------------------------
// ==============================================================
// 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" class="mt-3">${UI.icon('camera')} Foto einreichen</button>` : ''}
${my_submission_id ? `<span class="badge badge-success mt-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 class="text-xs-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" class="flex-col-gap-3">
<div class="form-group">
<label>Foto *</label>
<input type="file" id="challenge-foto-input" accept="image/*" required class="w-full">
</div>
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="challenge-dog-select" class="w-full">
<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" class="w-full">
</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 class="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 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" class="flex-col-gap-3">
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="gz-dog-select" class="w-full">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="gz-uhrzeit" required class="w-full">
</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" class="w-full">
</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" class="w-full">
</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 };
})();