1402 lines
57 KiB
JavaScript
1402 lines
57 KiB
JavaScript
/* ============================================================
|
||
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)}
|
||
· ${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)}
|
||
· ${wochentageLabel}
|
||
${z.ort_name ? ` · ${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 };
|
||
|
||
})();
|