/* ============================================================
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 = `
${UI.icon('paw-print')} Treffen
${UI.icon('camera')} Challenge
${UI.icon('clock')} Stamm-Gassis
${UI.icon('clock')} Stamm-Gassi-Zeiten
${UI.icon('plus')} Meine Zeit eintragen
`;
// 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 = `
${UI.icon('dog')}
Noch keine Treffen in deiner Nähe.
Erstes Treffen planen
`;
document.getElementById('walks-first-btn')?.addEventListener('click', _showCreateForm);
return;
}
// Heute + zukünftige Treffen
const heute = _data.filter(w => _isToday(w.datum));
const upcoming = _data.filter(w => !_isToday(w.datum) && !_isPast(w.datum));
let html = '';
if (heute.length) {
html += `${UI.icon('star')} Heute
`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `${UI.icon('calendar-dots')} Demnächst
`;
html += upcoming.map(w => _walkCardHTML(w)).join('');
}
el.innerHTML = `${html}
`;
el.querySelectorAll('.walks-card').forEach(card => {
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
});
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 `
${_fmtDateShort(w.datum)}
${w.uhrzeit}
${UI.escape(w.titel)}
${w.ort_name ? `
${UI.icon('map-pin')} ${UI.escape(w.ort_name)}
` : ''}
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}
${isOwn ? 'Mein Treffen ' : ''}
${w._isPending ? `⏳ Sync ausstehend ` : ''}
›
${_appState.user ? `
${UI.icon('note-pencil')} ` : ''}
`;
}
// ----------------------------------------------------------
// 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 `Zusage `;
if (status === 'maybe') return `Vielleicht `;
if (status === 'no') return `Absage `;
return `Eingeladen `;
}
function _avatarInitials(name) {
const parts = (name || '?').trim().split(/\s+/);
const initials = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: parts[0].slice(0, 2);
return initials.toUpperCase();
}
function _invitationRowHTML(inv) {
return `
${_avatarInitials(inv.user_name)}
${UI.escape(inv.user_name)}
${inv.hunde ? `
${UI.icon('dog')} ${UI.escape(inv.hunde)}
` : ''}
${_rsvpBadge(inv.status)}
`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _openDetail(walkId) {
let walk, participantData;
try {
walk = await API.walks.get(walkId);
if (_appState.user) {
try { participantData = await API.walks.participants(walkId); } catch {}
}
} catch (err) {
UI.toast.error(err.message);
return;
}
const isOwn = _appState.user?.id === walk.user_id;
const isJoined = walk.teilnehmer?.some(t => t.user_id === _appState.user?.id);
const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer;
const isPast = _isPast(walk.datum);
const spots = walk.max_teilnehmer - walk.teilnehmer_count;
const myRsvp = participantData?.my_rsvp ?? null;
const isInvited = !!myRsvp;
const invitations = participantData?.invitations ?? [];
// Teilnehmerliste mit Hundefotos
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => {
const dogsHTML = (t.hunde_liste || []).map(d => {
const av = d.foto_url
? ` `
: `
`;
return `
${av}
${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}
`;
}).join('');
return `
${_avatarInitials(t.user_name)}
${UI.escape(t.user_name)}
${dogsHTML ? `
${dogsHTML}
` : ''}
`;
}).join('')
: '';
// Einladungsliste
const invListHTML = invitations.length
? invitations.map(inv => _invitationRowHTML(inv)).join('')
: `Noch keine Einladungen.
`;
// RSVP-Section für eingeladene Nutzer
const rsvpSectionHTML = (isInvited && !isOwn) ? `
${UI.icon('check-circle')} Deine Antwort
${UI.icon('check')} Zusagen
${UI.icon('question')} Vielleicht
${UI.icon('x')} Absagen
` : '';
const body = `
${walk.beschreibung ? `
${UI.escape(walk.beschreibung)}
` : ''}
${rsvpSectionHTML}
${UI.icon('users')} Einladungen
${isOwn && !isPast ? `
${UI.icon('user-plus')} Einladen ` : ''}
${invListHTML}
${walk.teilnehmer?.length ? `
${UI.icon('check-circle')} Beigetreten (${walk.teilnehmer_count}/${walk.max_teilnehmer})
${teilnehmerHTML}
` : ''}
${UI.icon('star')} Bewertung
${UI.icon('images')} Fotos
${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? `
${UI.icon('camera')} Foto hinzufügen
` : ''}
${(walk.photos || []).length === 0
? `
Noch keine Fotos.
`
: (walk.photos || []).map(p => `
${p.user_id === _appState.user?.id || isOwn ? `
✕
` : ''}
`).join('')}
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
${isOwn && !isPast ? `
${UI.icon('x-circle')} Treffen stornieren
` : ''}
${isJoined && !isOwn ? `
${UI.icon('sign-out')} Nicht mehr teilnehmen
` : ''}
`;
let footer;
if (isOwn) {
footer = `
${UI.icon('pencil-simple')} Bearbeiten
Schließen
`;
} else if (!_appState.user) {
footer = `
Schließen
Anmelden zum Beitreten
`;
} else if (isJoined) {
footer = `
Schließen
`;
} else if (isPast || isFull) {
footer = `Schließen `;
} else {
footer = `
Schließen
${UI.icon('dog')} Mitmachen
`;
}
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 = `
✕ `;
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 => `
${_avatarInitials(f.friend_name)}
${UI.escape(f.friend_name)}
${UI.icon('paper-plane-tilt')} Einladen
`).join('')
: `Alle Freunde wurden bereits eingeladen.
`;
const body = `
${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr
${walk.ort_name ? `· ${UI.escape(walk.ort_name)}` : ''}
${listHTML}
`;
const footer = `
Zurück
`;
UI.modal.open({ title: `${UI.icon('user-plus')} Freunde einladen`, body, footer });
document.getElementById('invite-back')?.addEventListener('click', () => {
UI.modal.close();
_openDetail(walk.id);
});
document.querySelectorAll('.walks-invite-send').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.walks-invite-row');
const friendId = parseInt(row.dataset.friendId);
const name = row.dataset.friendName;
await UI.asyncButton(btn, async () => {
await API.walks.invite(walk.id, friendId);
row.innerHTML = `
${_avatarInitials(name)}
${UI.escape(name)}
Eingeladen
`;
UI.toast.success(`${name} eingeladen.`);
});
});
});
}
// ----------------------------------------------------------
// Beitreten-Formular (Hunde wählen)
// ----------------------------------------------------------
function _showJoinForm(walk) {
const dogs = _appState.dogs || [];
const dogsHtml = dogs.length
? dogs.map(d => `
${UI.icon('dog')} ${UI.escape(d.name)}
`).join('')
: `Keine Hunde im Profil — du kannst trotzdem mitmachen.
`;
const body = `
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr
${walk.ort_name ? `${UI.icon('map-pin')} ${UI.escape(walk.ort_name)}` : ''}
`;
const footer = `
Abbrechen
${UI.icon('dog')} Mitmachen
`;
UI.modal.open({ title: `Treffen beitreten`, body, footer });
document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('join-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('join-confirm');
const checked = [...document.querySelectorAll('[name="dog"]:checked')];
const dogIds = checked.map(cb => parseInt(cb.value));
await UI.asyncButton(btn, async () => {
const res = await API.walks.join(walk.id, dogIds);
const idx = _data.findIndex(w => w.id === walk.id);
if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count;
UI.modal.close();
_renderList();
_renderMarkers();
UI.toast.success(`Du nimmst teil! 🎉`);
});
});
}
// ----------------------------------------------------------
// Treffen erstellen / bearbeiten — Formular
// ----------------------------------------------------------
function _showCreateForm(prefill = {}) {
const today = new Date().toISOString().slice(0, 10);
_showWalkForm(null, { datum: today, uhrzeit: '10:00', ...prefill });
}
function _showEditForm(walk) {
_showWalkForm(walk);
}
function _showWalkForm(walk, defaults = {}) {
const isEdit = !!walk;
const v = walk || defaults;
// Location-State (verwaltet außerhalb des DOM)
let _locLat = v.lat != null ? parseFloat(v.lat) : null;
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
let _locName = v.ort_name || null;
const _pinSvg = ' ';
const body = `
`;
const footer = `
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('calendar-dots')} Treffen planen`}
Abbrechen
`;
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 = 'Keine Orte in der Nähe gefunden.
';
} else {
sugEl.innerHTML = suggestions.map(s => `
${UI.icon(_sourceIcon(s.source))}
${UI.escape(s.name)}
${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}
`).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)
// ----------------------------------------------------------
// ==============================================================
// 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 = `Konnte Challenge nicht laden.
`;
}
}
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 = `
${UI.escape(challenge.thema)}
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} – ${_fmtDate(challenge.end_date)}
· ${UI.icon('timer')} Noch ${dayLabel}
${canSubmit ? `
${UI.icon('camera')} Foto einreichen ` : ''}
${my_submission_id ? `
${UI.icon('check')} Du hast bereits teilgenommen ` : ''}
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
${submissions.length === 0
? `
Noch keine Fotos — sei der Erste! 📸
`
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
`;
// 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 `
${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}
${s.caption ? `
${UI.escape(s.caption)}
` : ''}
${UI.icon(voted ? 'heart-fill' : 'heart')} ${s.votes}
`;
}
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 = 'Noch keine vergangenen Challenges.
'; return; }
el.innerHTML = `` +
winners.map(w => {
if (!w.winner) return `
${UI.escape(w.challenge.thema)} Kein Gewinner
`;
return `
${UI.escape(w.challenge.thema)}
${UI.escape(w.winner.user_name)} · ${w.winner.votes} ❤️
`;
}).join('') +
`
`;
} catch {}
}
async function _showSubmitForm() {
if (!_challengeData) return;
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `${UI.escape(d.name)} `).join('');
UI.modal.open({
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
body: `
Foto *
${dogs.length ? `
Hund
Kein Hund
${dogOptions}
` : ''}
Bildunterschrift
`,
footer: `
Abbrechen
Einreichen
`,
});
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 = `Konnte Gassi-Zeiten nicht laden.
`;
}
}
function _renderGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
if (!_gassiZeiten.length) {
el.innerHTML = `
${UI.icon('clock')}
Noch keine Stamm-Gassi-Zeiten in deiner Nähe.
Trag deine regelmäßigen Zeiten ein — andere finden dich dann!
`;
return;
}
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
let html = '';
if (myZeiten.length) {
html += `Meine Zeiten
`;
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
}
if (andereZeiten.length) {
html += `In deiner Nähe
`;
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
? `${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'} `
: '';
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
return `
${z.dog_foto_url
? `
`
: `
${UI.icon('paw-print')}
`}
${UI.escape(z.dog_name || z.user_name || '?')}
${z.dog_rasse ? `${UI.escape(z.dog_rasse)} ` : ''}
${!z.aktiv ? `Pausiert ` : ''}
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
· ${wochentageLabel}
${z.ort_name ? ` · ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
${distLabel}
${z.notiz ? `
${UI.escape(z.notiz)}
` : ''}
${z.is_mine ? `
${UI.icon(z.aktiv ? 'pause' : 'play')}
${UI.icon('trash')}
` : `
${UI.icon('chat-circle')} Mitmachen
`}
`;
}
async function _showGassiZeitForm() {
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `${UI.escape(d.name)} `).join('');
const wdBtns = _WOCHENTAGE.map(w =>
` ${w.label} `
).join('');
UI.modal.open({
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
body: `
${dogs.length ? `
Hund
Kein Hund
${dogOptions}
` : ''}
Uhrzeit *
Ort (optional)
Notiz (optional)
`,
footer: `
Abbrechen
Speichern
`,
});
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 };
})();