diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 56528dd..634be79 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -187,6 +187,131 @@ async def decline_request(friendship_id: int, user=Depends(get_current_user)): return {"ok": True} +@router.get("/activity") +async def get_activity(user=Depends(get_current_user)): + """Aggregierter Aktivitäts-Feed der Freunde (max. 30 Einträge, neueste zuerst).""" + import json + uid = user["id"] + + with db() as conn: + # Alle akzeptierten Freunde ermitteln + friend_rows = conn.execute(""" + SELECT CASE WHEN requester_id=? THEN addressee_id ELSE requester_id END AS fid + FROM friendships + WHERE (requester_id=? OR addressee_id=?) AND status='accepted' + """, (uid, uid, uid)).fetchall() + + friend_ids = [r["fid"] for r in friend_rows] + if not friend_ids: + return [] + + ph = ",".join("?" * len(friend_ids)) + + # Tagebuch-Einträge der Freunde + diary_rows = conn.execute(f""" + SELECT + 'diary' AS type, + u.id AS user_id, + u.name AS user_name, + u.avatar_url, + d.name AS dog_name, + d.foto_url AS dog_foto, + dg.titel AS text, + dg.created_at + FROM diary dg + JOIN dogs d ON d.id = dg.dog_id + JOIN users u ON u.id = d.user_id + WHERE d.user_id IN ({ph}) + ORDER BY dg.created_at DESC + LIMIT 30 + """, friend_ids).fetchall() + + # Gesundheitseinträge der Freunde (nur Typ + Datum, kein Inhalt) + health_rows = conn.execute(f""" + SELECT + 'health' AS type, + u.id AS user_id, + u.name AS user_name, + u.avatar_url, + d.name AS dog_name, + d.foto_url AS dog_foto, + h.created_at + FROM health h + JOIN dogs d ON d.id = h.dog_id + JOIN users u ON u.id = d.user_id + WHERE d.user_id IN ({ph}) + ORDER BY h.created_at DESC + LIMIT 30 + """, friend_ids).fetchall() + + # Gassi-Treffen der Freunde + walk_rows = conn.execute(f""" + SELECT + 'walk' AS type, + u.id AS user_id, + u.name AS user_name, + u.avatar_url, + NULL AS dog_name, + NULL AS dog_foto, + w.titel AS text, + w.created_at + FROM walks w + JOIN users u ON u.id = w.user_id + WHERE w.user_id IN ({ph}) + ORDER BY w.created_at DESC + LIMIT 30 + """, friend_ids).fetchall() + + # Neue Hunde (angelegt in den letzten 30 Tagen) + new_dog_rows = conn.execute(f""" + SELECT + 'new_dog' AS type, + u.id AS user_id, + u.name AS user_name, + u.avatar_url, + d.name AS dog_name, + d.foto_url AS dog_foto, + d.created_at + FROM dogs d + JOIN users u ON u.id = d.user_id + WHERE d.user_id IN ({ph}) + AND d.created_at >= datetime('now', '-30 days') + ORDER BY d.created_at DESC + LIMIT 30 + """, friend_ids).fetchall() + + _ICON = { + "diary": "book-open", + "health": "heart", + "walk": "paw-print", + "new_dog": "dog", + } + _TEXT = { + "health": "Hat einen Gesundheitseintrag hinzugefügt", + "new_dog": "Hat einen neuen Hund eingetragen", + } + + items = [] + for row in [*diary_rows, *health_rows, *walk_rows, *new_dog_rows]: + d = dict(row) + t = d["type"] + items.append({ + "type": t, + "user_id": d["user_id"], + "user_name": d["user_name"], + "avatar_url": d.get("avatar_url"), + "dog_name": d.get("dog_name"), + "dog_foto": d.get("dog_foto"), + "text": _TEXT.get(t) or (d.get("text") or ""), + "created_at": d["created_at"], + "icon": _ICON[t], + }) + + # Zusammenführen und nach created_at absteigend sortieren, max. 30 + items.sort(key=lambda x: x["created_at"] or "", reverse=True) + return items[:30] + + @router.delete("/{friend_user_id}") async def remove_friend(friend_user_id: int, user=Depends(get_current_user)): uid = user["id"] diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 29f0cee..eb812fe 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -2486,8 +2486,10 @@ textarea.form-control { gap: var(--space-2); } .events-map { - flex: 1; - position: relative; + flex: 1; + position: relative; + min-height: 400px; + max-height: calc(100vh - 200px); } .events-month-label { font-size: var(--text-sm); @@ -4793,13 +4795,252 @@ textarea.form-control { .adm-stats-grid { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); margin-bottom: var(--space-5); + width: 100%; + min-width: 0; } @media (min-width: 480px) { - .adm-stats-grid { grid-template-columns: repeat(3, 1fr); } + .adm-stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (min-width: 768px) { - .adm-stats-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } + .adm-stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } +} + LOST DOG + ============================================================ */ + +/* Karte für einen vermissten Hund */ +.lost-card { + display: flex; + gap: var(--space-3); + align-items: flex-start; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-left: 4px solid var(--c-danger, #e74c3c); + border-radius: var(--radius-md); + padding: var(--space-3); + margin-bottom: var(--space-3); + cursor: pointer; + transition: background var(--transition-fast), box-shadow var(--transition-fast); +} +.lost-card:hover { + background: var(--c-surface-2); + box-shadow: var(--shadow-sm); +} + +/* Foto links in der Karte */ +.lost-card-foto { + width: 72px; + height: 72px; + border-radius: var(--radius-md); + object-fit: cover; + flex-shrink: 0; +} + +/* Platzhalter wenn kein Foto */ +.lost-card-foto-placeholder { + width: 72px; + height: 72px; + border-radius: var(--radius-md); + background: var(--c-surface-2); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; +} + +/* Text-Block rechts neben dem Foto */ +.lost-card-body { + flex: 1; + min-width: 0; +} + +/* Name des vermissten Hundes */ +.lost-card-name { + font-weight: var(--weight-semibold); + font-size: var(--text-base); + margin-bottom: var(--space-1); + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +/* Kurzbeschreibung */ +.lost-card-desc { + font-size: var(--text-sm); + color: var(--c-text); + margin: 0 0 var(--space-1); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Metadaten-Zeile (Datum, Entfernung) */ +.lost-card-meta { + font-size: var(--text-xs); + color: var(--c-text-secondary); +} + +/* Badge "Gefunden!" — grüner Überlagerungsstreifen */ +.lost-badge-gefunden { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: var(--c-success, #27ae60); + color: #fff; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + padding: 2px var(--space-2); + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: .04em; +} + +/* Badge "Meine Meldung" */ +.lost-badge-own { + display: inline-flex; + align-items: center; + background: var(--c-warning-subtle, #fff3cd); + color: var(--c-warning-dark, #856404); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + padding: 2px var(--space-2); + border-radius: var(--radius-full); +} + +/* Entfernungs-Pill */ +.lost-dist-pill { + margin-left: auto; + white-space: nowrap; + font-size: var(--text-sm); + color: var(--c-text-secondary); +} + +/* Karten-Wrapper auf der Lost-Seite */ +.lost-map-wrap { + height: 280px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--c-surface-2); + margin-bottom: 2px; +} + +/* OSM-Attribution unter der Karte */ +.lost-map-attribution { + font-size: 10px; + color: var(--c-text-secondary); + text-align: right; + padding: 2px var(--space-2) 0; + margin-bottom: var(--space-4); +} + +/* Info-Zeile über der Liste ("X vermisste Hunde …") */ +.lost-info-text { + font-size: var(--text-sm); + color: var(--c-text-secondary); + margin-bottom: var(--space-3); +} + FRIENDS ACTIVITY FEED + ============================================================ */ +.fr-activity-timeline { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.fr-activity-item { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); +} + +.fr-activity-avatar-wrap { + position: relative; + flex-shrink: 0; +} + +.fr-activity-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--c-primary); + display: block; +} + +.fr-activity-avatar--initial { + background: var(--c-primary-subtle); + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--weight-bold); + color: var(--c-primary); + font-size: var(--text-sm); +} + +.fr-activity-icon-badge { + position: absolute; + bottom: -3px; + right: -3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--c-surface); +} + +.fr-activity-body { + flex: 1; + min-width: 0; +} + +.fr-activity-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1); + margin-bottom: 2px; +} + +.fr-activity-user { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text); +} + +.fr-activity-dog { + font-size: var(--text-xs); + padding: 1px 6px; + border-radius: var(--radius-full); + background: var(--c-surface-2); + color: var(--c-text-secondary); + white-space: nowrap; +} + +.fr-activity-text { + font-size: var(--text-sm); + color: var(--c-text-secondary); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.fr-activity-time { + font-size: var(--text-xs); + color: var(--c-text-muted); + margin-top: 2px; } diff --git a/backend/static/js/api.js b/backend/static/js/api.js index eabce2a..431e9bb 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -335,6 +335,7 @@ const API = (() => { const friends = { list() { return get('/friends/'); }, search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); }, + activity() { return get('/friends/activity'); }, sendRequest(userId) { return post(`/friends/request/${userId}`, {}); }, accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); }, decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index e161725..1399a7a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '126'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 6c9b6b9..4a15929 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -41,8 +41,6 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ function _render() { _container.innerHTML = ` -
-
${TABS.map(t => ` @@ -54,7 +52,6 @@ window.Page_admin = (() => {
-
`; _container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => { diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 8a911f9..6c55686 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -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: `
${typ.icon}
`, iconSize: [32, 32], iconAnchor: [16, 32], }); - const m = L.marker([ev.lat, ev.lon], { icon }) - .addTo(_map) - .on('click', () => _showDetail(ev.id)); + const popup = ` +
+ ${UI.escHtml(ev.titel)}
+ ${datum}
+ ${ev.ort_name ? `📍 ${UI.escHtml(ev.ort_name)}
` : ''} + ${ev.beschreibung ? `${UI.escHtml(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}
` : ''} + Details +
+ `; + 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 }; })(); diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index b939086..3b1a4f9 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -104,6 +104,9 @@ window.Page_friends = (() => {
+ +
+
`; @@ -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 = ` +
+
Aktivitäten
+
+ +
+
+ `; + + 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 = ` +
+
Aktivitäten
+
+ +

+ Noch keine Aktivitäten. Füge Freunde hinzu! +

+
+
+ `; + return; + } + + el.innerHTML = ` +
+
Aktivitäten
+
+ ${items.map(item => _activityItem(item)).join('')} +
+
+ `; + } + + function _activityItem(item) { + const ago = _timeAgo(item.created_at); + const text = item.text || ''; + const dogLabel = item.dog_name + ? `${_esc(item.dog_name)}` + : ''; + + const avatar = item.dog_foto + ? `${_esc(item.dog_name || '')}` + : item.avatar_url + ? `${_esc(item.user_name)}` + : `
+ ${_esc((item.user_name || '?')[0].toUpperCase())} +
`; + + return ` +
+
+ ${avatar} +
+ +
+
+
+
+ ${_esc(item.user_name)} + ${dogLabel} +
+ ${text ? `
${_esc(text)}
` : ''} +
${_esc(ago)}
+
+
+ `; + } + + 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) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 4373142..dcde7ef 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v154'; +const CACHE_VERSION = 'by-v156'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten