diff --git a/backend/database.py b/backend/database.py index 11e9efb..ed3ef16 100644 --- a/backend/database.py +++ b/backend/database.py @@ -675,6 +675,20 @@ def _migrate(conn_factory): """) logger.info("Migration: dog_shares Tabelle bereit.") + # Event-RSVP + conn.executescript(""" + CREATE TABLE IF NOT EXISTS event_rsvp ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status TEXT DEFAULT 'going', + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(event_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_event_rsvp_event ON event_rsvp(event_id); + """) + logger.info("Migration: event_rsvp Tabelle bereit.") + # Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User) _ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()} if _ev_cols.get("user_id") == 1: @@ -704,3 +718,21 @@ def _migrate(conn_factory): ON events(external_id) WHERE external_id IS NOT NULL; """) logger.info("Migration: events.user_id NOT NULL Constraint entfernt.") + + # Service-Angebote: Sitting + Walks Matching + conn.executescript(""" + CREATE TABLE IF NOT EXISTS service_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, -- 'sitting' oder 'walks' + beschreibung TEXT, + preis_pro_tag REAL, + lat REAL, + lon REAL, + radius_km INTEGER DEFAULT 10, + aktiv INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv); + CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type); + """) diff --git a/backend/main.py b/backend/main.py index 0fa1980..cdaa64f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -77,6 +77,7 @@ from routes.import_data import router as import_router from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router from routes.widget import router as widget_router from routes.notifications import router as notifications_router +from routes.services import router as services_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -107,6 +108,7 @@ app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen" app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"]) app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) +app.include_router(services_router, prefix="/api/services", tags=["Services"]) # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py index 7c4f449..c066959 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -25,6 +25,9 @@ def _haversine(lat1, lon1, lat2, lon2): # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ +class RsvpCreate(BaseModel): + status: str = 'going' # 'going' | 'maybe' + class EventCreate(BaseModel): titel: str datum: str # YYYY-MM-DD @@ -65,7 +68,8 @@ async def list_events( q = """ SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, - e.quelle + e.quelle, + (SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.status = 'aktiv' @@ -101,7 +105,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)): data.lat, data.lon, data.ort_name, data.typ, data.beschreibung, data.link)) row = conn.execute( - "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, " + "0 AS rsvp_count " "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (cur.lastrowid,) ).fetchone() @@ -115,7 +120,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)): async def get_event(event_id: int): with db() as conn: row = conn.execute( - "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, " + "(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count " "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (event_id,) ).fetchone() @@ -142,7 +148,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren cols = ', '.join(f"{k} = ?" for k in updates) conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id]) row = conn.execute( - "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, " + "(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count " "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (event_id,) ).fetchone() @@ -161,3 +168,53 @@ async def delete_event(event_id: int, user=Depends(get_current_user)): if ev['user_id'] == 0 or ev['user_id'] != user['id']: raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.") conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,)) + + +# ------------------------------------------------------------------ +# POST /api/events/{id}/rsvp +# ------------------------------------------------------------------ +@router.post("/{event_id}/rsvp", status_code=201) +async def rsvp_event(event_id: int, data: RsvpCreate, user=Depends(get_current_user)): + if data.status not in ('going', 'maybe'): + raise HTTPException(400, "Status muss 'going' oder 'maybe' sein.") + with db() as conn: + ev = conn.execute("SELECT id FROM events WHERE id = ? AND status = 'aktiv'", (event_id,)).fetchone() + if not ev: + raise HTTPException(404, "Event nicht gefunden.") + conn.execute( + "INSERT OR REPLACE INTO event_rsvp (event_id, user_id, status) VALUES (?, ?, ?)", + (event_id, user['id'], data.status) + ) + count = conn.execute( + "SELECT COUNT(*) FROM event_rsvp WHERE event_id = ? AND status = 'going'", (event_id,) + ).fetchone()[0] + return {"event_id": event_id, "status": data.status, "rsvp_count": count} + + +# ------------------------------------------------------------------ +# DELETE /api/events/{id}/rsvp +# ------------------------------------------------------------------ +@router.delete("/{event_id}/rsvp", status_code=204) +async def cancel_rsvp(event_id: int, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + "DELETE FROM event_rsvp WHERE event_id = ? AND user_id = ?", + (event_id, user['id']) + ) + + +# ------------------------------------------------------------------ +# GET /api/events/{id}/rsvp +# ------------------------------------------------------------------ +@router.get("/{event_id}/rsvp") +async def list_rsvp(event_id: int): + with db() as conn: + rows = conn.execute( + "SELECT r.user_id, u.name, r.status " + "FROM event_rsvp r " + "JOIN users u ON u.id = r.user_id " + "WHERE r.event_id = ? " + "ORDER BY r.created_at ASC", + (event_id,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 2823623..76aeb1c 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -1,11 +1,14 @@ """BAN YARO — Forum (Sprint 11)""" -import os, uuid, json +import os, uuid, json, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user, get_current_user_optional +from routes.push import send_push_to_user + +logger = logging.getLogger(__name__) router = APIRouter() @@ -295,9 +298,30 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current WHERE p.id = ?""", (cur.lastrowid,) ).fetchone() + # Thread-Owner ermitteln für Push-Notification + owner_row = conn.execute( + "SELECT user_id FROM forum_threads WHERE id = ?", (thread_id,) + ).fetchone() + owner_id = owner_row['user_id'] if owner_row else None + pd = dict(row) pd['foto_urls'] = [] pd['user_liked'] = False + + # Push-Notification an Thread-Owner (nicht an sich selbst) + if owner_id and owner_id != user['id']: + try: + commenter_name = pd.get('autor_name') or 'Jemand' + send_push_to_user(owner_id, { + "type": "forum_reply", + "title": "Neue Antwort auf deinen Beitrag", + "body": f"{commenter_name} hat auf deinen Beitrag geantwortet", + "tag": f"forum-{thread_id}", + "data": {"page": "forum", "id": thread_id}, + }) + except Exception: + logger.exception("Push-Notification für Forum-Reply fehlgeschlagen (nicht kritisch)") + return pd diff --git a/backend/routes/health.py b/backend/routes/health.py index 7c26678..e758cf8 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -243,6 +243,22 @@ async def upload_dokument( return {"datei_url": datei_url, "datei_typ": datei_typ} +# ------------------------------------------------------------------ +# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf +# ------------------------------------------------------------------ +@router.get("/{dog_id}/health/gewicht") +async def list_gewicht(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + rows = conn.execute( + """SELECT datum, wert AS gewicht FROM health + WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL + ORDER BY datum ASC""", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung # ------------------------------------------------------------------ diff --git a/backend/static/css/components.css b/backend/static/css/components.css index eb812fe..ef6e17c 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -5044,3 +5044,93 @@ textarea.form-control { color: var(--c-text-muted); margin-top: 2px; } + +/* health chart extras (weight chart) */ +.health-chart-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); margin-bottom: var(--space-2); } +.health-chart-svg { width: 100%; height: auto; display: block; } +.health-chart-labels { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: var(--space-1); } +.health-chart-empty { font-size: var(--text-sm); color: var(--c-text-muted); text-align: center; padding: var(--space-4) 0; } + +/* ============================================================ + RSVP — Event-Teilnahme + ============================================================ */ +.event-rsvp-bar { display:flex; gap:var(--space-2); align-items:center; margin:var(--space-3) 0 var(--space-2); flex-wrap:wrap; } +.event-rsvp-btn { display:inline-flex; align-items:center; gap:var(--space-1); padding:6px 14px; border-radius:var(--radius); border:1.5px solid var(--c-border); background:var(--c-surface); color:var(--c-text-secondary); font-size:var(--text-sm); font-weight:500; cursor:pointer; transition:background .15s,color .15s,border-color .15s; } +.event-rsvp-btn:hover { border-color:var(--c-primary); color:var(--c-primary); } +.event-rsvp-btn.active { background:var(--c-primary); border-color:var(--c-primary); color:#fff; } +.event-attendees { font-size:var(--text-sm); color:var(--c-text-secondary); cursor:pointer; display:inline-flex; align-items:center; gap:var(--space-1); } +.event-attendees:hover { color:var(--c-primary); } +.ev-attendees-list { display:flex; flex-wrap:wrap; gap:var(--space-1); margin-top:var(--space-2); } +.ev-attendee-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:999px; background:var(--c-surface-2); font-size:var(--text-xs); color:var(--c-text-secondary); } + +/* ============================================================ + SERVICES / MATCHING (Sitting & Walks Anbieter-Suche) + ============================================================ */ +.svc-matching-layout { display:flex; flex-direction:column; gap:var(--space-4); padding:var(--space-3) 0; } +.svc-own-offer { padding:var(--space-4); } +.svc-own-offer-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-3); } +.svc-own-offer-title { font-weight:var(--weight-semibold); font-size:var(--text-base); } +.svc-login-hint { font-size:var(--text-sm); color:var(--c-text-muted); } +.svc-toggle { position:relative; display:inline-block; width:44px; height:24px; cursor:pointer; } +.svc-toggle input { opacity:0; width:0; height:0; } +.svc-toggle-slider { position:absolute; inset:0; background:var(--c-border); border-radius:var(--radius-full); transition:background var(--transition-fast); } +.svc-toggle-slider::before { content:''; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform var(--transition-fast); box-shadow:0 1px 3px rgba(0,0,0,.2); } +.svc-toggle input:checked + .svc-toggle-slider { background:var(--c-primary); } +.svc-toggle input:checked + .svc-toggle-slider::before { transform:translateX(20px); } +.svc-offer-form { display:flex; flex-direction:column; gap:var(--space-3); } +.svc-offer-form--hidden { display:none; } +.svc-hint { color:var(--c-text-secondary); font-size:var(--text-sm); text-align:center; padding:var(--space-6) 0; } +.svc-results-list { display:flex; flex-direction:column; gap:var(--space-3); } +.svc-card { display:flex; align-items:flex-start; gap:var(--space-3); padding:var(--space-4); background:var(--c-surface); border-radius:var(--radius-lg); border:1px solid var(--c-border-light); box-shadow:var(--shadow-xs); } +.svc-card-avatar { width:44px; height:44px; border-radius:var(--radius-full); background:var(--c-primary-subtle); color:var(--c-primary); display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; } +.svc-card-body { flex:1; min-width:0; } +.svc-card-name { font-weight:var(--weight-semibold); margin-bottom:var(--space-1); } +.svc-card-dist { font-size:var(--text-xs); color:var(--c-text-muted); margin-bottom:var(--space-1); } +.svc-card-desc { font-size:var(--text-sm); color:var(--c-text-secondary); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; } +.svc-card-side { display:flex; flex-direction:column; align-items:flex-end; gap:var(--space-2); flex-shrink:0; } +.svc-card-price { font-weight:var(--weight-bold); color:var(--c-primary); font-size:var(--text-sm); } + +/* ============================================================ + HELP TOOLTIP + ============================================================ */ +.by-help-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; height: 18px; + border-radius: 50%; + background: var(--c-surface-2); + color: var(--c-text-secondary); + border: none; + cursor: pointer; + vertical-align: middle; + margin-left: 4px; + flex-shrink: 0; + transition: background .15s; +} +.by-help-btn:hover { background: var(--c-primary-subtle, #e8f0fe); color: var(--c-primary); } + +.by-help-tooltip { + position: absolute; + z-index: 9000; + background: var(--c-text); + color: var(--c-bg); + font-size: var(--text-xs); + line-height: 1.5; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + max-width: 240px; + box-shadow: 0 4px 12px rgba(0,0,0,.15); + pointer-events: none; +} +/* SVG-Icon-Variante (Phosphor) */ +.empty-state-icon > svg.ph-icon, +svg.empty-state-icon { + width: 56px; + height: 56px; + color: var(--c-text-muted); + opacity: .5; +} +.empty-state-cta { + margin-top: var(--space-2); +} diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 431e9bb..1f46743 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -139,6 +139,9 @@ const API = (() => { symptomCheck(dogId, symptoms) { return post(`/dogs/${dogId}/health/symptom-check`, { symptoms }); }, + gewichtVerlauf(dogId) { + return get(`/dogs/${dogId}/health/gewicht`); + }, }; // ---------------------------------------------------------- @@ -230,10 +233,13 @@ const API = (() => { const q = new URLSearchParams(params).toString(); return get(`/events${q ? '?' + q : ''}`); }, - get(id) { return get(`/events/${id}`); }, - create(data) { return post('/events', data); }, - update(id, data) { return patch(`/events/${id}`, data); }, - delete(id) { return del(`/events/${id}`); }, + get(id) { return get(`/events/${id}`); }, + create(data) { return post('/events', data); }, + update(id, data) { return patch(`/events/${id}`, data); }, + delete(id) { return del(`/events/${id}`); }, + rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); }, + cancelRsvp(id) { return del(`/events/${id}/rsvp`); }, + listRsvp(id) { return get(`/events/${id}/rsvp`); }, }; // ---------------------------------------------------------- @@ -445,6 +451,20 @@ const API = (() => { delete(id) { return del(`/notifications/${id}`); }, }; + // ---------------------------------------------------------- + // SERVICE-ANGEBOTE (Sitting & Walks Matching) + // ---------------------------------------------------------- + const services = { + list(type, lat = null, lon = null, radius = 20) { + const p = new URLSearchParams({ type, radius }); + if (lat !== null) { p.set('lat', lat); p.set('lon', lon); } + return get(`/services?${p}`); + }, + me() { return get('/services/me'); }, + upsert(data) { return post('/services', data); }, + deactivate(id) { return del(`/services/${id}`); }, + }; + const importData = { notestation(dogId, file) { const fd = new FormData(); @@ -477,7 +497,7 @@ const API = (() => { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, webcal, importData, sharing, widget, notifications, + friends, chat, webcal, importData, sharing, widget, notifications, services, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1399a7a..5033d89 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 = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '129'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index c6b7849..77754f8 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -206,6 +206,20 @@ window.Page_diary = (() => { UI.setLoading(btn, false); } + // ---------------------------------------------------------- + // EMPTY-STATE HELPER + // ---------------------------------------------------------- + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + // ---------------------------------------------------------- // LISTE RENDERN — Timeline gruppiert nach Monat // ---------------------------------------------------------- @@ -214,12 +228,12 @@ window.Page_diary = (() => { if (!listEl) return; if (_entries.length === 0) { - listEl.innerHTML = UI.emptyState({ - icon: '', - title: 'Noch keine Einträge', - text: 'Halte besondere Momente mit deinem Hund fest.', - action: ``, - }); + listEl.innerHTML = _emptyState( + 'book-open', + 'Noch keine Tagebucheinträge', + 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.', + `` + ); listEl.querySelector('#diary-first-entry') ?.addEventListener('click', () => _showForm(null)); return; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 397ba6e..c1a4ce5 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -582,6 +582,7 @@ window.Page_dog_profile = (() => { { min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
- +
diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 6c55686..333ab72 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -39,6 +39,7 @@ window.Page_events = (() => { let _map = null; let _markers = []; let _clusterGroup = null; + let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null } // ---------------------------------------------------------- // Phosphor-Icon-Helper @@ -47,6 +48,17 @@ window.Page_events = (() => { return ``; } + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + // ---------------------------------------------------------- // init // ---------------------------------------------------------- @@ -133,7 +145,11 @@ window.Page_events = (() => { const filtered = _filtered(); if (!filtered.length) { - listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' }); + listEl.innerHTML = _emptyState( + 'calendar-blank', + 'Keine Events in der Nähe', + 'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.' + ); return; } @@ -185,6 +201,7 @@ window.Page_events = (() => { ${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''} ${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''} + ${ev.rsvp_count ? `${_icon('users')} ${ev.rsvp_count} nehmen teil` : ''} ${ev.link ? `
${_icon('arrow-square-out')} Details @@ -326,12 +343,26 @@ window.Page_events = (() => { let ev; try { ev = await API.events.get(id); } catch { return; } - const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; - const color = TYP_COLOR[ev.typ] || '#6b7280'; - const d = new Date(ev.datum + 'T00:00:00'); - const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); - const isOwn = _state.user?.id === ev.user_id; - const isVdh = ev.quelle === 'vdh'; + const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; + const color = TYP_COLOR[ev.typ] || '#6b7280'; + const d = new Date(ev.datum + 'T00:00:00'); + const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + const isOwn = _state.user?.id === ev.user_id; + const isVdh = ev.quelle === 'vdh'; + const myRsvp = _myRsvp[id] ?? null; + + // RSVP-Bar (nur für eingeloggte User) + const rsvpBar = _state.user ? ` +
+ + + ${ev.rsvp_count ? `${_icon('users')} ${ev.rsvp_count} nehmen teil` : ``} +
+ ` : (ev.rsvp_count ? `
${_icon('users')} ${ev.rsvp_count} nehmen teil
` : ''); const body = `
@@ -348,6 +379,8 @@ window.Page_events = (() => {
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
+ ${rsvpBar} +
`; const footer = isOwn ? ` @@ -365,6 +398,80 @@ window.Page_events = (() => { UI.modal.close(); setTimeout(() => _openForm(ev), 50); }); document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev)); + + // RSVP-Buttons + document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => { + btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus)); + }); + } + + async function _handleRsvp(eventId, status) { + const current = _myRsvp[eventId] ?? null; + try { + if (current === status) { + // Toggle off → absagen + await API.events.cancelRsvp(eventId); + _myRsvp[eventId] = null; + } else { + const res = await API.events.rsvp(eventId, status); + _myRsvp[eventId] = status; + // Teilnehmerzähler aktualisieren + _updateAttendeeCount(eventId, res.rsvp_count); + } + // Button-Styles aktualisieren + document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => { + btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? '')); + }); + // Bei Absage Zähler neu laden + if (current === status) { + const attendees = await API.events.listRsvp(eventId); + const goingCount = attendees.filter(a => a.status === 'going').length; + _updateAttendeeCount(eventId, goingCount); + } + } catch (e) { UI.toast(e.message, 'error'); } + } + + function _updateAttendeeCount(eventId, count) { + // Im Modal + const span = document.getElementById(`ev-attendees-${eventId}`); + if (span) { + if (count > 0) { + span.innerHTML = `${_icon('users')} ${count} nehmen teil`; + span.style.display = ''; + } else { + span.style.display = 'none'; + } + } + // In der Listenansicht (Event-Objekt aktualisieren) + const ev = _events.find(x => x.id === eventId); + if (ev) { + ev.rsvp_count = count; + // Karte neu rendern falls sichtbar + const card = document.querySelector(`[data-ev-id="${eventId}"]`); + if (card) card.outerHTML = _cardHTML(ev); + } + } + + async function _showAttendees(eventId) { + const panel = document.getElementById(`ev-attendees-panel-${eventId}`); + if (!panel) return; + if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; } + try { + const attendees = await API.events.listRsvp(eventId); + if (!attendees.length) { panel.innerHTML = '

Noch keine Zusagen.

'; } + else { + panel.innerHTML = ` +
+ ${attendees.map(a => ` + + ${a.status === 'going' ? _icon('check-circle') : _icon('question')} + ${UI.escHtml(a.name)} + + `).join('')} +
`; + } + panel.dataset.loaded = '1'; + } catch { /* ignore */ } } async function _deleteEvent(ev) { @@ -548,6 +655,14 @@ window.Page_events = (() => { return; } + // Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal) + const attendeesBtn = e.target.closest('[data-ev-attendees]'); + if (attendeesBtn) { + e.stopPropagation(); + _showAttendees(parseInt(attendeesBtn.dataset.evAttendees)); + return; + } + // Karten-Klick → Detail const card = e.target.closest('[data-ev-id]'); if (card) { _showDetail(parseInt(card.dataset.evId)); } diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 3b1a4f9..3dd4b00 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -366,19 +366,15 @@ window.Page_friends = (() => { const el = _container.querySelector('#fr-list'); if (!list.length) { - el.innerHTML = ` -
- -

Noch keine Hundefreunde

-

- Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage. -

-
- `; + el.innerHTML = _emptyState( + 'users-three', + 'Noch keine Freunde', + 'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.', + `` + ); + el.querySelector('#fr-empty-search')?.addEventListener('click', () => { + _container.querySelector('#fr-search')?.focus(); + }); return; } @@ -774,6 +770,17 @@ window.Page_friends = (() => { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + // ---------------------------------------------------------- return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat }; diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 2f36caa..8bc709b 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -321,6 +321,11 @@ window.Page_health = (() => { } catch (err) { // silent fail } + try { + _data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId); + } catch (err) { + _data['gewicht_chart'] = []; + } } // ---------------------------------------------------------- @@ -347,15 +352,33 @@ window.Page_health = (() => { _bindTabEvents(content); } + // ---------------------------------------------------------- + // EMPTY-STATE HELPER + // ---------------------------------------------------------- + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + // ---------------------------------------------------------- // IMPFUNGEN — mit Ampel-Status // ---------------------------------------------------------- function _renderImpfungen(entries) { const addBtn = ``; + const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.'); - if (!entries.length) return UI.emptyState({ - icon: '', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn - }); + if (!entries.length) return _emptyState( + 'syringe', + 'Noch keine Impfungen', + `Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`, + addBtn + ); const items = entries.map(e => { const ampel = _impfAmpel(e.naechstes); @@ -453,7 +476,8 @@ window.Page_health = (() => {
`; })() : ''; - const chart = sorted.length >= 2 ? _weightChart(sorted) : ''; + const chartEntries = _data['gewicht_chart'] || []; + const chart = _renderWeightChart(chartEntries); const items = sorted.slice().reverse().map(e => `
{
${deltaHtml}
- ${chart ? `
${chart}
` : ''} + ${chart ? `
+
+ Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')} +
+ ${chart} +
` : ''}
${items}
${addBtn}
`; @@ -556,6 +586,62 @@ window.Page_health = (() => { `; } + // ---------------------------------------------------------- + // GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht) + // ---------------------------------------------------------- + function _renderWeightChart(entries) { + // entries: [{datum, gewicht}, ...] + if (!entries || entries.length < 2) { + return '

Mindestens 2 Gewichtseinträge für den Verlauf nötig.

'; + } + + const W = 300, H = 120, PAD = 24; + const weights = entries.map(e => e.gewicht); + const min = Math.min(...weights), max = Math.max(...weights); + const range = max - min || 1; + + // x: gleichmäßig verteilt, y: normalisiert + const pts = entries.map((e, i) => { + const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD); + const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD); + return { x, y, ...e }; + }); + + const polyline = pts.map(p => `${p.x},${p.y}`).join(' '); + const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`; + + // Datenpunkte + Tooltips als title-Elemente + const circles = pts.map(p => + ` + ${p.datum}: ${p.gewicht} kg + ` + ).join(''); + + const gId = `wg${Math.random().toString(36).slice(2, 7)}`; + return ` +
+
Gewichtsverlauf
+ + + + + + + + + + ${circles} + ${min} + ${max} + +
+ ${entries[0].datum} + ${entries[entries.length - 1].datum} +
+
+ `; + } + // ---------------------------------------------------------- // LÄUFIGKEIT — Timeline + Vorhersage // ---------------------------------------------------------- @@ -927,7 +1013,10 @@ window.Page_health = (() => { const uploadField = t === 'dokument' ? `
- +
` : ''; diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index 36c405e..b65bcf4 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -257,12 +257,12 @@ window.Page_lost = (() => { if (!listEl) return; if (_reports.length === 0) { - listEl.innerHTML = UI.emptyState({ - icon : '🐾', - title : 'Keine vermissten Hunde', - text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.', - action: ``, - }); + listEl.innerHTML = _emptyState( + 'magnifying-glass', + 'Aktuell kein vermisster Hund gemeldet', + 'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.', + `` + ); listEl.querySelector('#lost-empty-report') ?.addEventListener('click', _showReportForm); return; @@ -680,6 +680,17 @@ window.Page_lost = (() => { .replace(/"/g, '"'); } + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index a86c51d..fad314e 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -45,13 +45,31 @@ window.Page_routes = (() => { return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + function _emptyState(icon, title, text, cta = '') { + return `
+ +
${title}
+ ${text ? `

${text}

` : ''} + ${cta ? `
${cta}
` : ''} +
`; + } + async function init(container, appState) { _container = container; _appState = appState; _render(); _loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden try { _userPos = await API.getLocation(); } catch {} - _loadData(); + await _loadData(); + + // Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen + const params = new URLSearchParams((location.hash.split('?')[1] || '')); + const deepId = params.get('id'); + if (deepId) { + _openDetail(parseInt(deepId, 10)); + } } async function _loadLeaflet() { @@ -460,20 +478,12 @@ window.Page_routes = (() => { `; } else { // Noch gar keine eigenen Routen - grid.innerHTML = `
-
🥾
-

Deine erste Gassi-Route

-

Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.

-
-
${UI.icon('map-trifold')}GPS-Aufzeichnung
-
${UI.icon('camera')}Fotos entlang der Strecke
-
🐾Hundetauglichkeit bewerten
-
${UI.icon('download-simple')}GPX-Download für Navi
-
${UI.icon('map-pin')}Restaurants & Parkplätze
-
${UI.icon('lock')}Privat oder öffentlich
-
- -
`; + grid.innerHTML = _emptyState( + 'map-trifold', + 'Noch keine Routen', + 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.', + `` + ); document.getElementById('rk-empty-rec')?.addEventListener('click', () => { App.navigate('map'); setTimeout(() => window.Page_map?.startRecording?.(), 600); @@ -688,6 +698,8 @@ window.Page_routes = (() => { const footer = ` + + ${isOwn ? ` @@ -700,6 +712,23 @@ window.Page_routes = (() => { document.getElementById('rd-close')?.addEventListener('click', UI.modal.close); document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route)); + // Teilen-Button + document.getElementById('rd-share')?.addEventListener('click', () => { + const shareUrl = location.origin + '/#routes?id=' + route.id; + if (navigator.share) { + navigator.share({ title: route.name, url: shareUrl }).catch(() => {}); + } else { + navigator.clipboard.writeText(shareUrl).then(() => { + UI.toast.success('Link kopiert!'); + }).catch(() => { + UI.toast.error('Link konnte nicht kopiert werden.'); + }); + } + }); + + // An Freund senden + document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route)); + // Sichtbarkeit toggle document.getElementById('rd-vis')?.addEventListener('click', async () => { try { @@ -1108,6 +1137,7 @@ window.Page_routes = (() => { @@ -1173,6 +1203,71 @@ window.Page_routes = (() => { return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]); } + // ---------------------------------------------------------- + // An Freund senden + // ---------------------------------------------------------- + async function _openSendToFriendModal(route) { + const shareUrl = location.origin + '/#routes?id=' + route.id; + + // Freunde laden + let friends = []; + try { + friends = await API.friends.list(); + } catch (err) { + UI.toast.error('Freunde konnten nicht geladen werden.'); + return; + } + + if (!friends.length) { + UI.toast.info('Du hast noch keine Freunde hinzugefügt.'); + return; + } + + const friendRows = friends.map(f => { + const initial = (f.name || '?')[0].toUpperCase(); + return `
+
${_esc(initial)}
+ ${_esc(f.name || 'Anonym')} +
`; + }).join(''); + + const body = `
${friendRows}
`; + const footer = ``; + + UI.modal.open({ + title: `${UI.icon('chat-circle-dots')} An Freund senden`, + body, + footer, + }); + + document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('rk-friend-list')?.addEventListener('click', async e => { + const row = e.target.closest('.rk-friend-row'); + if (!row) return; + + const partnerId = parseInt(row.dataset.id, 10); + const partnerName = row.dataset.name; + + try { + const conv = await API.chat.start(partnerId); + const convId = conv.id; + const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`; + await API.chat.send(convId, text); + UI.modal.close(); + UI.toast.success(`Gesendet an ${partnerName}`); + } catch (err) { + UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler')); + } + }); + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js index 4573799..5f1e516 100644 --- a/backend/static/js/pages/sitting.js +++ b/backend/static/js/pages/sitting.js @@ -18,13 +18,17 @@ window.Page_sitting = (() => { // ---------------------------------------------------------- // State // ---------------------------------------------------------- - let _container = null; - let _state = null; - let _tab = 'suchen'; // suchen | profil | anfragen - let _sitters = []; - let _mySitter = null; - let _myRequests = []; - let _inbox = []; + let _container = null; + let _state = null; + let _tab = 'suchen'; // suchen | profil | anfragen | matching + let _sitters = []; + let _mySitter = null; + let _myRequests = []; + let _inbox = []; + // Matching-State + let _matchResults = null; // null = noch nicht geladen + let _matchLoading = false; + let _myServiceOffer = null; // eigenes Angebot (type='sitting') // ---------------------------------------------------------- // init @@ -46,6 +50,7 @@ window.Page_sitting = (() => {
+ ${_state.user ? ` @@ -70,6 +75,7 @@ window.Page_sitting = (() => { tasks.push(API.sitting.me()); tasks.push(API.sitting.requests()); tasks.push(API.sitting.inbox()); + tasks.push(API.services.me()); } try { @@ -78,6 +84,8 @@ window.Page_sitting = (() => { _mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null; _myRequests = results[2]?.status === 'fulfilled' ? results[2].value : []; _inbox = results[3]?.status === 'fulfilled' ? results[3].value : []; + const myOffers = results[4]?.status === 'fulfilled' ? results[4].value : []; + _myServiceOffer = myOffers?.find(o => o.type === 'sitting') || null; } catch {} _renderTab(); @@ -92,6 +100,7 @@ window.Page_sitting = (() => { if (_tab === 'suchen') _renderSuchen(content); if (_tab === 'profil') _renderProfil(content); if (_tab === 'anfragen') _renderAnfragen(content); + if (_tab === 'matching') _renderMatching(content); } // ---- Tab: Sitter suchen ---- @@ -441,6 +450,239 @@ window.Page_sitting = (() => { }); } + // ---------------------------------------------------------- + // Tab: Anbieter in deiner Nähe (service_offers Matching) + // ---------------------------------------------------------- + function _renderMatching(el) { + const offerActive = _myServiceOffer?.aktiv; + const offerDesc = _myServiceOffer?.beschreibung || ''; + const offerPreis = _myServiceOffer?.preis_pro_tag ?? ''; + + el.innerHTML = ` +
+ + +
+
+ ${UI.icon('handshake')} Mein Angebot + ${_state.user ? ` + + ` : ``} +
+ ${_state.user ? ` +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ ` : ''} +
+ + +
+
+ + +
+
+

Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.

+
+
+ +
+ `; + + // Toggle + document.getElementById('svc-offer-toggle')?.addEventListener('change', async e => { + const form = document.getElementById('svc-offer-form'); + if (e.target.checked) { + form?.classList.remove('svc-offer-form--hidden'); + } else { + form?.classList.add('svc-offer-form--hidden'); + if (_myServiceOffer) { + try { + await API.services.deactivate(_myServiceOffer.id); + _myServiceOffer = { ..._myServiceOffer, aktiv: 0 }; + UI.toast('Angebot deaktiviert.'); + } catch (err) { UI.toast(err.message, 'error'); } + } + } + }); + + // GPS + document.getElementById('svc-gps-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('svc-gps-btn'); + btn.disabled = true; + try { + const pos = await API.getLocation(); + document.getElementById('svc-lat').value = pos.lat.toFixed(6); + document.getElementById('svc-lon').value = pos.lon.toFixed(6); + UI.toast('Position gespeichert.'); + } catch { UI.toast('GPS nicht verfügbar.', 'error'); } + btn.disabled = false; + }); + + // Formular speichern + document.getElementById('svc-offer-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const form = e.target; + const fd = new FormData(form); + const submitBtn = form.querySelector('[type="submit"]'); + + // Wenn noch keine Position gespeichert, GPS holen + if (!fd.get('lat') || !fd.get('lon')) { + try { + const pos = await API.getLocation(); + document.getElementById('svc-lat').value = pos.lat.toFixed(6); + document.getElementById('svc-lon').value = pos.lon.toFixed(6); + fd.set('lat', pos.lat.toFixed(6)); + fd.set('lon', pos.lon.toFixed(6)); + } catch { + UI.toast('Bitte GPS-Position ermitteln.', 'error'); + return; + } + } + + if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; } + try { + const payload = { + type: 'sitting', + beschreibung: fd.get('beschreibung') || null, + preis_pro_tag: fd.get('preis_pro_tag') ? parseFloat(fd.get('preis_pro_tag')) : null, + lat: parseFloat(fd.get('lat')), + lon: parseFloat(fd.get('lon')), + radius_km: parseInt(fd.get('radius_km')) || 10, + }; + _myServiceOffer = await API.services.upsert(payload); + UI.toast('Angebot gespeichert!'); + } catch (err) { + UI.toast(err.message, 'error'); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = `${UI.icon('floppy-disk')} Speichern`; + } + } + }); + + // Suche + document.getElementById('svc-find-btn')?.addEventListener('click', _searchProviders); + } + + async function _searchProviders() { + if (_matchLoading) return; + _matchLoading = true; + const btn = document.getElementById('svc-find-btn'); + if (btn) { btn.disabled = true; btn.innerHTML = `${UI.icon('spinner')} Suche…`; } + + const resultsEl = document.getElementById('svc-results'); + + let pos = null; + try { + pos = await API.getLocation(); + } catch { + UI.toast('GPS nicht verfügbar — Suche ohne Entfernung.', 'error'); + } + + try { + const offers = await API.services.list('sitting', pos?.lat ?? null, pos?.lon ?? null, 50); + _matchResults = offers; + _renderMatchResults(resultsEl, pos); + } catch (err) { + UI.toast(err.message, 'error'); + if (resultsEl) resultsEl.innerHTML = `

Fehler beim Laden.

`; + } + + _matchLoading = false; + if (btn) { + btn.disabled = false; + btn.innerHTML = `${UI.icon('map-pin')} Erneut suchen`; + } + } + + function _renderMatchResults(el, pos) { + if (!el) return; + const list = _matchResults || []; + + // Eigene Angebote ausblenden + const filtered = list.filter(o => o.user_id !== _state.user?.id); + + if (!filtered.length) { + el.innerHTML = `

Keine Anbieter in deiner Nähe gefunden.

`; + return; + } + + el.innerHTML = ` +
+ ${filtered.map(o => _serviceCardHTML(o)).join('')} +
+ `; + } + + function _serviceCardHTML(o) { + const dist = o.distanz_km != null ? `${o.distanz_km} km entfernt` : ''; + const preis = o.preis_pro_tag != null ? `${o.preis_pro_tag.toFixed(0)} €/Tag` : 'Preis anfragen'; + return ` +
+
${UI.icon('paw-print')}
+
+
${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}
+ ${dist ? `
${UI.icon('map-pin')} ${dist}
` : ''} + ${o.beschreibung ? `
${UI.escape(o.beschreibung)}
` : ''} +
+
+
${preis}
+ +
+
+ `; + } + + async function _openChatWithProvider(userId) { + if (!_state.user) { + UI.toast('Bitte zuerst anmelden.', 'error'); + App.navigate('settings'); + return; + } + try { + const { conversation_id } = await API.chat.start(userId); + App.navigate('chat', true, { conversation_id }); + } catch (err) { + UI.toast(err.message, 'error'); + } + } + // ---------------------------------------------------------- // Click-Handler // ---------------------------------------------------------- @@ -467,6 +709,13 @@ window.Page_sitting = (() => { return; } + // Anbieter kontaktieren + const chatBtn = e.target.closest('[data-svc-chat]'); + if (chatBtn) { + _openChatWithProvider(parseInt(chatBtn.dataset.svcChat)); + return; + } + // Anfragen-Aktionen const acceptBtn = e.target.closest('[data-sit-accept]'); const declineBtn = e.target.closest('[data-sit-decline]'); diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 8f1101e..52d21fd 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -268,6 +268,37 @@ const UI = (() => { .replace(/"/g, '"'); } + // ---------------------------------------------------------- + // HELP TOOLTIP — inline ? Badge mit Klick-Tooltip + // ---------------------------------------------------------- + function help(text) { + return ``; + } + + // Event-Delegation für Help-Tooltips — einmalig registrieren + document.addEventListener('click', e => { + const btn = e.target.closest('.by-help-btn'); + if (!btn) { + document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove()); + return; + } + e.stopPropagation(); + document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove()); + const tip = document.createElement('div'); + tip.className = 'by-help-tooltip'; + tip.textContent = btn.dataset.help; + document.body.appendChild(tip); + const r = btn.getBoundingClientRect(); + tip.style.top = (r.bottom + window.scrollY + 6) + 'px'; + tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px'; + const maxL = window.innerWidth - tip.offsetWidth - 8; + if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px'; + }); + // Öffentliche API return { toast, modal, @@ -276,7 +307,7 @@ const UI = (() => { emptyState, time, setupPhotoPreview, scrollTop, skeleton, icon: _svgIcon, - escape, + escape, help, }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index dcde7ef..7296707 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-v156'; +const CACHE_VERSION = 'by-v157'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten