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 = ` -
+ Noch keine Aktivitäten. Füge Freunde hinzu! +
+