Sprint 18: Lost-Dog CSS, Freunde-Aktivitäts-Feed, Events-Karte

This commit is contained in:
rene 2026-04-17 23:43:35 +02:00
parent cfdb3fbc19
commit 10d30bf565
8 changed files with 595 additions and 41 deletions

View file

@ -41,8 +41,6 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div style="max-width:720px;margin:0 auto;padding:var(--space-4)">
<!-- Tabs -->
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
@ -54,7 +52,6 @@ window.Page_admin = (() => {
<!-- Inhalt -->
<div id="adm-content"></div>
</div>
`;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => {

View file

@ -30,14 +30,15 @@ window.Page_events = (() => {
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
let _container = null;
let _state = null;
let _events = [];
let _filter = 'alle';
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
let _container = null;
let _state = null;
let _events = [];
let _filter = 'alle';
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
let _clusterGroup = null;
// ----------------------------------------------------------
// Phosphor-Icon-Helper
@ -155,8 +156,6 @@ window.Page_events = (() => {
}
}
listEl.innerHTML = html;
if (_view === 'karte') _renderMap(filtered);
}
function _cardHTML(ev) {
@ -203,48 +202,120 @@ window.Page_events = (() => {
async function _renderMap(filtered) {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await _loadLeaflet();
await _loadMarkerCluster();
if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
} else {
_markers.forEach(m => m.remove());
_markers = [];
}
// Cluster-Gruppe aufräumen und neu befüllen
if (_clusterGroup) {
_map.removeLayer(_clusterGroup);
}
_clusterGroup = L.markerClusterGroup();
_markers = [];
const bounds = [];
for (const ev of filtered) {
if (!ev.lat || !ev.lon) continue;
const color = TYP_COLOR[ev.typ] || '#6b7280';
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg)"><span style="transform:rotate(45deg)">${typ.icon}</span></div>`,
iconSize: [32, 32], iconAnchor: [16, 32],
});
const m = L.marker([ev.lat, ev.lon], { icon })
.addTo(_map)
.on('click', () => _showDetail(ev.id));
const popup = `
<div style="min-width:180px">
<strong>${UI.escHtml(ev.titel)}</strong><br>
<span style="color:#666;font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escHtml(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escHtml(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div>
`;
const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
_clusterGroup.addLayer(m);
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
}
if (bounds.length) _map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
setTimeout(() => _map.invalidateSize(), 50);
_map.addLayer(_clusterGroup);
if (bounds.length) {
_map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
} else {
// Versuche Nutzerstandort, sonst Deutschland-Übersicht
try {
const pos = await API.getLocation({ timeout: 5000 });
_map.setView([pos.lat, pos.lon], 10);
} catch {
_map.setView([51.1657, 10.4515], 6);
}
}
_map.invalidateSize();
setTimeout(() => _map.invalidateSize(), 100);
}
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
document.head.appendChild(link);
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _loadMarkerCluster() {
if (window.L && L.markerClusterGroup) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="MarkerCluster"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/MarkerCluster.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
const link2 = document.createElement('link');
link2.rel = 'stylesheet';
link2.href = '/css/MarkerCluster.Default.css';
link2.onload = res;
link2.onerror = res;
document.head.appendChild(link2);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="markercluster"]') ||
document.querySelector('script[src*="MarkerCluster"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js';
s.onload = resolve;
s.onerror = resolve; // Cluster ist optional — graceful degradation
document.head.appendChild(s);
});
});
}
@ -421,7 +492,7 @@ window.Page_events = (() => {
if (sourceBtn) {
_quellFilter = sourceBtn.dataset.evQuelle;
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
_renderList();
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
return;
}
@ -430,7 +501,7 @@ window.Page_events = (() => {
if (filterBtn) {
_filter = filterBtn.dataset.evTyp;
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
_renderList();
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
return;
}
@ -444,10 +515,19 @@ window.Page_events = (() => {
if (_view === 'karte') {
listEl.style.display = 'none';
mapEl.style.display = 'block';
// Erst div sichtbar machen, dann Karte initialisieren
_renderMap(_filtered());
} else {
listEl.style.display = '';
// Karte sauber entfernen
if (_map) {
_map.remove();
_map = null;
_clusterGroup = null;
_markers = [];
}
mapEl.style.display = 'none';
listEl.style.display = '';
_renderList();
}
return;
}
@ -473,6 +553,6 @@ window.Page_events = (() => {
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
return { init, refresh, openNew };
return { init, refresh, openNew, _openDetail: _showDetail };
})();

View file

@ -104,6 +104,9 @@ window.Page_friends = (() => {
<!-- Freundesliste -->
<div id="fr-list"></div>
<!-- Aktivitäten-Feed -->
<div id="fr-activity"></div>
</div>
`;
@ -165,6 +168,113 @@ window.Page_friends = (() => {
_renderFriends(data.friends || []);
_updateBadge((data.incoming || []).length);
} catch { /* silent — 401 bei abgemeldeter Session */ }
_loadActivity();
}
async function _loadActivity() {
if (!_appState.user) return;
const el = _container.querySelector('#fr-activity');
if (!el) return;
// Ladeindikator
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div style="text-align:center;padding:var(--space-6) 0;color:var(--c-text-muted);
font-size:var(--text-sm)">
<svg class="ph-icon" style="width:20px;height:20px;animation:spin 1s linear infinite"
aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg>
</div>
</div>
`;
try {
const items = await API.friends.activity();
_renderActivity(items || []);
} catch {
el.innerHTML = '';
}
}
function _renderActivity(items) {
const el = _container.querySelector('#fr-activity');
if (!el) return;
if (!items.length) {
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Noch keine Aktivitäten. Füge Freunde hinzu!
</p>
</div>
</div>
`;
return;
}
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div class="fr-activity-timeline">
${items.map(item => _activityItem(item)).join('')}
</div>
</div>
`;
}
function _activityItem(item) {
const ago = _timeAgo(item.created_at);
const text = item.text || '';
const dogLabel = item.dog_name
? `<span class="fr-activity-dog">${_esc(item.dog_name)}</span>`
: '';
const avatar = item.dog_foto
? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
${_esc((item.user_name || '?')[0].toUpperCase())}
</div>`;
return `
<div class="fr-activity-item">
<div class="fr-activity-avatar-wrap">
${avatar}
<div class="fr-activity-icon-badge">
<svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(item.icon)}"></use>
</svg>
</div>
</div>
<div class="fr-activity-body">
<div class="fr-activity-meta">
<span class="fr-activity-user">${_esc(item.user_name)}</span>
${dogLabel}
</div>
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
<div class="fr-activity-time">${_esc(ago)}</div>
</div>
</div>
`;
}
function _timeAgo(iso) {
if (!iso) return '';
const diff = Math.floor((Date.now() - new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).getTime()) / 1000);
if (diff < 60) return 'Gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
if (diff < 86400 * 7) return `vor ${Math.floor(diff / 86400)} Tagen`;
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function _updateBadge(count) {