Sprint 18: Lost-Dog CSS, Freunde-Aktivitäts-Feed, Events-Karte
This commit is contained in:
parent
cfdb3fbc19
commit
10d30bf565
8 changed files with 595 additions and 41 deletions
|
|
@ -187,6 +187,131 @@ async def decline_request(friendship_id: int, user=Depends(get_current_user)):
|
||||||
return {"ok": True}
|
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}")
|
@router.delete("/{friend_user_id}")
|
||||||
async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
|
async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
|
|
||||||
|
|
@ -2486,8 +2486,10 @@ textarea.form-control {
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.events-map {
|
.events-map {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
}
|
}
|
||||||
.events-month-label {
|
.events-month-label {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|
@ -4793,13 +4795,252 @@ textarea.form-control {
|
||||||
|
|
||||||
.adm-stats-grid {
|
.adm-stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@media (min-width: 480px) {
|
@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) {
|
@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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ const API = (() => {
|
||||||
const friends = {
|
const friends = {
|
||||||
list() { return get('/friends/'); },
|
list() { return get('/friends/'); },
|
||||||
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
||||||
|
activity() { return get('/friends/activity'); },
|
||||||
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
||||||
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
|
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
|
||||||
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
|
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ window.Page_admin = (() => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
function _render() {
|
function _render() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="max-width:720px;margin:0 auto;padding:var(--space-4)">
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||||||
${TABS.map(t => `
|
${TABS.map(t => `
|
||||||
|
|
@ -54,7 +52,6 @@ window.Page_admin = (() => {
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
<div id="adm-content"></div>
|
<div id="adm-content"></div>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
_container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => {
|
_container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => {
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,15 @@ window.Page_events = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
let _container = null;
|
let _container = null;
|
||||||
let _state = null;
|
let _state = null;
|
||||||
let _events = [];
|
let _events = [];
|
||||||
let _filter = 'alle';
|
let _filter = 'alle';
|
||||||
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
|
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
|
||||||
let _view = 'liste'; // liste | karte
|
let _view = 'liste'; // liste | karte
|
||||||
let _map = null;
|
let _map = null;
|
||||||
let _markers = [];
|
let _markers = [];
|
||||||
|
let _clusterGroup = null;
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Phosphor-Icon-Helper
|
// Phosphor-Icon-Helper
|
||||||
|
|
@ -155,8 +156,6 @@ window.Page_events = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listEl.innerHTML = html;
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
if (_view === 'karte') _renderMap(filtered);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _cardHTML(ev) {
|
function _cardHTML(ev) {
|
||||||
|
|
@ -203,48 +202,120 @@ window.Page_events = (() => {
|
||||||
async function _renderMap(filtered) {
|
async function _renderMap(filtered) {
|
||||||
const mapEl = document.getElementById('ev-map');
|
const mapEl = document.getElementById('ev-map');
|
||||||
if (!mapEl) return;
|
if (!mapEl) return;
|
||||||
|
|
||||||
await _loadLeaflet();
|
await _loadLeaflet();
|
||||||
|
await _loadMarkerCluster();
|
||||||
|
|
||||||
if (!_map) {
|
if (!_map) {
|
||||||
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
|
_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);
|
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 = [];
|
const bounds = [];
|
||||||
for (const ev of filtered) {
|
for (const ev of filtered) {
|
||||||
if (!ev.lat || !ev.lon) continue;
|
if (!ev.lat || !ev.lon) continue;
|
||||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
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({
|
const icon = L.divIcon({
|
||||||
className: '',
|
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>`,
|
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],
|
iconSize: [32, 32], iconAnchor: [16, 32],
|
||||||
});
|
});
|
||||||
const m = L.marker([ev.lat, ev.lon], { icon })
|
const popup = `
|
||||||
.addTo(_map)
|
<div style="min-width:180px">
|
||||||
.on('click', () => _showDetail(ev.id));
|
<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);
|
_markers.push(m);
|
||||||
bounds.push([ev.lat, ev.lon]);
|
bounds.push([ev.lat, ev.lon]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bounds.length) _map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
_map.addLayer(_clusterGroup);
|
||||||
setTimeout(() => _map.invalidateSize(), 50);
|
|
||||||
|
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() {
|
function _loadLeaflet() {
|
||||||
if (window.L) return Promise.resolve();
|
if (window.L) return Promise.resolve();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const link = document.createElement('link');
|
const cssLoaded = document.querySelector('link[href*="leaflet"]')
|
||||||
link.rel = 'stylesheet';
|
? Promise.resolve()
|
||||||
link.href = '/css/leaflet.css';
|
: new Promise(res => {
|
||||||
document.head.appendChild(link);
|
const link = document.createElement('link');
|
||||||
const s = document.createElement('script');
|
link.rel = 'stylesheet';
|
||||||
s.src = '/js/leaflet.js';
|
link.href = '/css/leaflet.css';
|
||||||
s.onload = resolve;
|
link.onload = res;
|
||||||
s.onerror = reject;
|
link.onerror = res;
|
||||||
document.head.appendChild(s);
|
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) {
|
if (sourceBtn) {
|
||||||
_quellFilter = sourceBtn.dataset.evQuelle;
|
_quellFilter = sourceBtn.dataset.evQuelle;
|
||||||
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
|
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
|
||||||
_renderList();
|
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,7 +501,7 @@ window.Page_events = (() => {
|
||||||
if (filterBtn) {
|
if (filterBtn) {
|
||||||
_filter = filterBtn.dataset.evTyp;
|
_filter = filterBtn.dataset.evTyp;
|
||||||
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
|
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
|
||||||
_renderList();
|
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,10 +515,19 @@ window.Page_events = (() => {
|
||||||
if (_view === 'karte') {
|
if (_view === 'karte') {
|
||||||
listEl.style.display = 'none';
|
listEl.style.display = 'none';
|
||||||
mapEl.style.display = 'block';
|
mapEl.style.display = 'block';
|
||||||
|
// Erst div sichtbar machen, dann Karte initialisieren
|
||||||
_renderMap(_filtered());
|
_renderMap(_filtered());
|
||||||
} else {
|
} else {
|
||||||
listEl.style.display = '';
|
// Karte sauber entfernen
|
||||||
|
if (_map) {
|
||||||
|
_map.remove();
|
||||||
|
_map = null;
|
||||||
|
_clusterGroup = null;
|
||||||
|
_markers = [];
|
||||||
|
}
|
||||||
mapEl.style.display = 'none';
|
mapEl.style.display = 'none';
|
||||||
|
listEl.style.display = '';
|
||||||
|
_renderList();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -473,6 +553,6 @@ window.Page_events = (() => {
|
||||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { init, refresh, openNew };
|
return { init, refresh, openNew, _openDetail: _showDetail };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ window.Page_friends = (() => {
|
||||||
<!-- Freundesliste -->
|
<!-- Freundesliste -->
|
||||||
<div id="fr-list"></div>
|
<div id="fr-list"></div>
|
||||||
|
|
||||||
|
<!-- Aktivitäten-Feed -->
|
||||||
|
<div id="fr-activity"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -165,6 +168,113 @@ window.Page_friends = (() => {
|
||||||
_renderFriends(data.friends || []);
|
_renderFriends(data.friends || []);
|
||||||
_updateBadge((data.incoming || []).length);
|
_updateBadge((data.incoming || []).length);
|
||||||
} catch { /* silent — 401 bei abgemeldeter Session */ }
|
} 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) {
|
function _updateBadge(count) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v154';
|
const CACHE_VERSION = 'by-v156';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue