From e56183b642c69fdd6e143f8443eb2827b93b4cb5 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 19 Apr 2026 09:40:35 +0200 Subject: [PATCH] Feature: Ratings, Lightbox, Forum-Standort, Notifications, Routen-Recording, Chat-Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bewertungssystem (ratings.py): Sterne für Sitter/Walks/Places/Routen - Admin: Server-Log-Viewer + OSM-Cache-Statistiken - Chat: "Neue Nachricht"-Button mit Freundesliste-Picker - Forum: 5 neue Kategorien, Standorteingabe (locationPicker), Absende-Toast, Lightbox - Freunde: Aktivitäts-Filter (Chips), Freundschaftsanfrage → In-App-Notification - Sitter: locationPicker statt manuelle Koordinateneingabe + ratingStars - Tagebuch: Bilder-Lightbox im Detail-View, iOS-Modal-Header-Fix (90svh) - Routen: Start/Stopp-Button wechselt Zustand, nutzt Page_map.isRecording() - Benachrichtigungen: Delete-Button sichtbar, typ-basierte Navigation, Toast-Feedback - OSM: globales Semaphore + 429-Retry-Logic; Scheduler: München-Umland, täglich - SW by-v225, APP_VER 202 --- backend/database.py | 4 + backend/main.py | 18 ++++ backend/routes/admin.py | 23 ++++ backend/routes/forum.py | 22 ++-- backend/routes/friends.py | 25 +++-- backend/routes/osm.py | 30 +++--- backend/routes/ratings.py | 124 +++++++++++++++++++++ backend/scheduler.py | 23 ++-- backend/static/css/components.css | 6 +- backend/static/js/app.js | 12 ++- backend/static/js/pages/admin.js | 53 ++++++++- backend/static/js/pages/chat.js | 54 +++++++++- backend/static/js/pages/diary.js | 131 +++++++++++++---------- backend/static/js/pages/forum.js | 62 +++++++++-- backend/static/js/pages/friends.js | 58 ++++++---- backend/static/js/pages/map.js | 12 ++- backend/static/js/pages/notifications.js | 82 +++++++++++--- backend/static/js/pages/routes.js | 32 +++++- backend/static/js/pages/sitting.js | 46 ++++---- backend/static/sw.js | 2 +- docker-compose.yml | 4 +- 21 files changed, 648 insertions(+), 175 deletions(-) create mode 100644 backend/routes/ratings.py diff --git a/backend/database.py b/backend/database.py index 95c77f8..193d41d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -482,6 +482,10 @@ def _migrate(conn_factory): ("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"), # Tagebuch-Medien: Cover-Bild markieren ("diary_media", "is_cover", "INTEGER NOT NULL DEFAULT 0"), + # Forum-Threads: optionaler Standort + ("forum_threads", "thread_lat", "REAL"), + ("forum_threads", "thread_lon", "REAL"), + ("forum_threads", "thread_ort", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index cdaa64f..b4a3b71 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ BAN YARO — FastAPI Hauptanwendung import os import logging +from collections import deque from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse @@ -13,10 +14,25 @@ from database import init_db import ki import scheduler as sched +# In-Memory Log-Buffer (letzte 500 Zeilen) +log_buffer: deque = deque(maxlen=500) + +class _BufferHandler(logging.Handler): + _fmt = logging.Formatter() + + def emit(self, record): + log_buffer.append({ + 't': self._fmt.formatTime(record, '%H:%M:%S'), + 'l': record.levelname, + 'm': record.getMessage(), + 'n': record.name, + }) + logging.basicConfig( level = logging.INFO, format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) +logging.getLogger().addHandler(_BufferHandler()) logger = logging.getLogger(__name__) @@ -78,6 +94,7 @@ from routes.sharing import dog_router as sharing_dog_router, share_router as from routes.widget import router as widget_router from routes.notifications import router as notifications_router from routes.services import router as services_router +from routes.ratings import router as ratings_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -109,6 +126,7 @@ 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"]) +app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) # ------------------------------------------------------------------ diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 2ddfa21..7f5b3c6 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -116,6 +116,14 @@ async def stats(user=Depends(require_mod)): media_count = media_diary + media_health routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0] events_total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + osm_total = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0] + osm_tiles = conn.execute("SELECT COUNT(*) FROM osm_tiles").fetchone()[0] + osm_by_type = { + row[0]: row[1] + for row in conn.execute( + "SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC" + ).fetchall() + } return { "users_total": users_total, @@ -131,6 +139,9 @@ async def stats(user=Depends(require_mod)): "media_count": media_count, "routes_total": routes_total, "events_total": events_total, + "osm_total": osm_total, + "osm_tiles": osm_tiles, + "osm_by_type": osm_by_type, } @@ -438,6 +449,18 @@ async def system_info(user=Depends(require_admin)): } +# ------------------------------------------------------------------ +# GET /api/admin/logs +# ------------------------------------------------------------------ +@router.get("/logs") +async def get_logs(lines: int = 200, level: str = "", user=Depends(require_admin)): + from main import log_buffer + entries = list(log_buffer) + if level: + entries = [e for e in entries if e['l'] == level.upper()] + return entries[-lines:] + + # ------------------------------------------------------------------ # GET /api/admin/audit # ------------------------------------------------------------------ diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 76aeb1c..d75cf28 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -15,16 +15,20 @@ router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") FORUM_DIR = os.path.join(MEDIA_DIR, "forum") -KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', 'tauschboerse'] +KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', + 'spaziergang', 'ausflug', 'training', 'ernaehrung', 'probleme', 'tauschboerse'] # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class ThreadCreate(BaseModel): - kategorie: str = 'allgemein' - titel: str - text: str + kategorie: str = 'allgemein' + titel: str + text: str + thread_lat: Optional[float] = None + thread_lon: Optional[float] = None + thread_ort: Optional[str] = None class PostCreate(BaseModel): text: str @@ -143,9 +147,10 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: cur = conn.execute( - """INSERT INTO forum_threads (user_id, kategorie, titel, text) - VALUES (?, ?, ?, ?)""", - (user['id'], data.kategorie, data.titel.strip(), data.text.strip()) + """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (user['id'], data.kategorie, data.titel.strip(), data.text.strip(), + data.thread_lat, data.thread_lon, data.thread_ort) ) row = conn.execute( """SELECT t.*, u.name AS autor_name @@ -523,7 +528,8 @@ async def members_map(): AND forum_lat IS NOT NULL AND forum_lon IS NOT NULL""" ).fetchall() - return [dict(r) for r in rows] + return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 2), 'lon': round(r['lon'], 2)} + for r in rows] # ------------------------------------------------------------------ diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 634be79..724c2b4 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -96,7 +96,7 @@ async def search_users(q: str = "", user=Depends(get_current_user)): FROM dogs d WHERE d.user_id=u.id AND d.is_public=1) AS dogs_json FROM users u WHERE u.id != ? - AND u.name LIKE ? + AND norm(u.name) LIKE norm(?) AND NOT EXISTS ( SELECT 1 FROM friendships f WHERE (f.requester_id=? AND f.addressee_id=u.id) @@ -142,6 +142,19 @@ async def send_request(target_id: int, user=Depends(get_current_user)): (uid, target_id) ) + # In-App Benachrichtigung + Badge + try: + with db() as conn: + conn.execute( + """INSERT INTO notifications (user_id, type, title, body, data) + VALUES (?, 'friend_request', 'Neue Freundschaftsanfrage', ?, ?)""", + (target_id, + f"{user['name']} möchte dein Freund sein.", + '{"page":"friends"}') + ) + except Exception: + pass + try: from routes.push import send_push_to_user send_push_to_user(target_id, { @@ -223,7 +236,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = d.user_id WHERE d.user_id IN ({ph}) ORDER BY dg.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Gesundheitseinträge der Freunde (nur Typ + Datum, kein Inhalt) @@ -241,7 +254,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = d.user_id WHERE d.user_id IN ({ph}) ORDER BY h.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Gassi-Treffen der Freunde @@ -259,7 +272,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = w.user_id WHERE w.user_id IN ({ph}) ORDER BY w.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Neue Hunde (angelegt in den letzten 30 Tagen) @@ -277,7 +290,7 @@ async def get_activity(user=Depends(get_current_user)): WHERE d.user_id IN ({ph}) AND d.created_at >= datetime('now', '-30 days') ORDER BY d.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() _ICON = { @@ -309,7 +322,7 @@ async def get_activity(user=Depends(get_current_user)): # Zusammenführen und nach created_at absteigend sortieren, max. 30 items.sort(key=lambda x: x["created_at"] or "", reverse=True) - return items[:30] + return items[:50] @router.delete("/{friend_user_id}") diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 51c1f17..d462313 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -20,6 +20,9 @@ CACHE_ZOOM = 12 CACHE_DAYS = 14 OVERPASS_URL = 'https://overpass-api.de/api/interpreter' +# Globales Limit: max 2 gleichzeitige Overpass-Anfragen (Prewarm + User geteilt) +_overpass_sem = asyncio.Semaphore(2) + OSM_QUERIES = { 'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;', 'dog_park': '[out:json][timeout:25];(way["leisure"="dog_park"]({bbox});node["leisure"="dog_park"]({bbox});way["leisure"="park"]["dog"="yes"]({bbox});node["leisure"="park"]["dog"="yes"]({bbox}););out center;', @@ -61,10 +64,17 @@ def _covering_tiles(south, west, north, east, zoom): # Overpass-Fetch + Cache # ------------------------------------------------------------------ async def _fetch_overpass(query): - async with httpx.AsyncClient(timeout=40) as client: - r = await client.post(OVERPASS_URL, data={'data': query}) - r.raise_for_status() - return r.json().get('elements', []) + for attempt in range(3): + async with _overpass_sem: + async with httpx.AsyncClient(timeout=40) as client: + r = await client.post(OVERPASS_URL, data={'data': query}) + if r.status_code != 429: + r.raise_for_status() + return r.json().get('elements', []) + logger.warning(f"Overpass 429 (Versuch {attempt + 1}/3)") + # Semaphore freigeben, dann warten + await asyncio.sleep(45 * (attempt + 1)) + raise Exception("Overpass 429 nach 3 Versuchen") def _stale_tiles(poi_type, tiles): stale = [] @@ -143,11 +153,7 @@ async def get_pois( stale = _stale_tiles(type, tiles) if stale and not fast: - sem = asyncio.Semaphore(3) - async def _limited(x, y): - async with sem: - await _fetch_and_store_tile(type, x, y) - await asyncio.gather(*[_limited(x, y) for (x, y) in stale]) + await asyncio.gather(*[_fetch_and_store_tile(type, x, y) for (x, y) in stale]) fetched_fresh = True with db() as conn: @@ -313,12 +319,8 @@ async def analyze_region( tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) async def _warmup(): - sem = asyncio.Semaphore(3) - async def _limited(poi_type, x, y): - async with sem: - await _fetch_and_store_tile(poi_type, x, y) tasks = [ - _limited(pt, x, y) + _fetch_and_store_tile(pt, x, y) for pt in OSM_QUERIES for (x, y) in _stale_tiles(pt, tiles) ] diff --git a/backend/routes/ratings.py b/backend/routes/ratings.py new file mode 100644 index 0000000..dba63f5 --- /dev/null +++ b/backend/routes/ratings.py @@ -0,0 +1,124 @@ +"""BAN YARO — Bewertungssystem (Ratings)""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +VALID_TYPES = {'walk', 'sitting', 'place', 'route'} + +# Tabelle → bewertung + anz_bewertungen aktualisieren +TABLE_MAP = { + 'walk': 'walks', + 'sitting': 'sitters', + 'place': 'places', + 'route': 'routes', +} + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class RatingCreate(BaseModel): + target_type: str + target_id: int + stars: int + kommentar: Optional[str] = None + + +# ------------------------------------------------------------------ +# POST /api/ratings — Bewertung abgeben oder aktualisieren +# ------------------------------------------------------------------ +@router.post("", status_code=200) +async def upsert_rating(data: RatingCreate, user=Depends(get_current_user)): + if data.target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + if not (1 <= data.stars <= 5): + raise HTTPException(400, "Sterne müssen zwischen 1 und 5 liegen.") + if data.kommentar and len(data.kommentar) > 200: + raise HTTPException(400, "Kommentar darf maximal 200 Zeichen lang sein.") + + table = TABLE_MAP[data.target_type] + kommentar = data.kommentar.strip() if data.kommentar else None + + with db() as conn: + # Prüfen ob Zielobjekt existiert + row = conn.execute(f"SELECT id FROM {table} WHERE id=?", (data.target_id,)).fetchone() + if not row: + raise HTTPException(404, "Objekt nicht gefunden.") + + # Upsert + conn.execute(""" + INSERT INTO ratings (user_id, target_type, target_id, stars, kommentar) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, target_type, target_id) + DO UPDATE SET stars=excluded.stars, kommentar=excluded.kommentar, created_at=datetime('now') + """, (user['id'], data.target_type, data.target_id, data.stars, kommentar)) + + # Durchschnitt berechnen und Zieltabelle aktualisieren + agg = conn.execute(""" + SELECT AVG(CAST(stars AS REAL)) AS avg_stars, COUNT(*) AS cnt + FROM ratings + WHERE target_type=? AND target_id=? + """, (data.target_type, data.target_id)).fetchone() + + conn.execute( + f"UPDATE {table} SET bewertung=?, anz_bewertungen=? WHERE id=?", + (round(agg['avg_stars'], 2), agg['cnt'], data.target_id) + ) + + return {"bewertung": round(agg['avg_stars'], 2), "anz_bewertungen": agg['cnt']} + + +# ------------------------------------------------------------------ +# GET /api/ratings/{type}/{id} — Bewertungen für ein Objekt laden +# WICHTIG: Feste Route vor {param} in main.py registrieren +# ------------------------------------------------------------------ +@router.get("/{target_type}/{target_id}") +async def get_ratings(target_type: str, target_id: int): + if target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.stars, r.kommentar, r.created_at, + u.name AS user_name + FROM ratings r + JOIN users u ON u.id = r.user_id + WHERE r.target_type=? AND r.target_id=? + ORDER BY r.created_at DESC + """, (target_type, target_id)).fetchall() + + agg = conn.execute(""" + SELECT AVG(CAST(stars AS REAL)) AS avg_stars, COUNT(*) AS cnt + FROM ratings WHERE target_type=? AND target_id=? + """, (target_type, target_id)).fetchone() + + return { + "bewertung": round(agg['avg_stars'], 2) if agg['avg_stars'] else 0, + "anz_bewertungen": agg['cnt'], + "ratings": [dict(r) for r in rows], + } + + +# ------------------------------------------------------------------ +# GET /api/ratings/me/{type}/{id} — Eigene Bewertung für ein Objekt +# WICHTIG: Diese Route muss VOR /{target_type}/{target_id} stehen! +# ------------------------------------------------------------------ +@router.get("/me/{target_type}/{target_id}") +async def get_my_rating(target_type: str, target_id: int, user=Depends(get_current_user)): + if target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + + with db() as conn: + row = conn.execute(""" + SELECT stars, kommentar FROM ratings + WHERE user_id=? AND target_type=? AND target_id=? + """, (user['id'], target_type, target_id)).fetchone() + + if not row: + return {"stars": None, "kommentar": None} + return dict(row) diff --git a/backend/scheduler.py b/backend/scheduler.py index a404fc0..a476ce9 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -58,7 +58,7 @@ def start(): ) _scheduler.add_job( _job_prewarm_cities, - CronTrigger(day_of_week='sun', hour=1), # jeden Sonntag 01:00 Uhr + CronTrigger(hour=2, minute=0), # täglich 02:00 Uhr id="prewarm_cities", replace_existing=True, misfire_grace_time=7200, @@ -71,14 +71,6 @@ def start(): id="import_events_startup", replace_existing=True, ) - # Einmalig beim Start (nach 90s) — OSM-Tiles für Großstädte vorwärmen - _scheduler.add_job( - _job_prewarm_cities, - 'date', - run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), - id="prewarm_cities_startup", - replace_existing=True, - ) # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen _scheduler.add_job( _job_seed_breeds, @@ -482,6 +474,15 @@ _CITIES_DE = [ (52.2763, 8.0479, "Osnabrück"), (53.8755, 10.7000, "Lübeck-Ost"), (51.9333, 6.8667, "Borken"), + # München Umland + (48.0734, 11.9661, "Ebersberg"), + (47.9947, 11.6612, "Holzkirchen"), + (48.0628, 11.6574, "Ottobrunn"), + (48.2456, 11.3712, "Dachau"), + (48.1667, 11.7833, "Vaterstetten"), + (48.2667, 11.6667, "Garching"), + (48.0667, 11.4667, "Gauting"), + (47.9833, 11.3000, "Starnberg"), ] async def _job_prewarm_cities(): @@ -493,7 +494,7 @@ async def _job_prewarm_cities(): REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden logger.info("City-Prewarm Job startet…") - sem = asyncio.Semaphore(2) + sem = asyncio.Semaphore(1) total_fetched = 0 cities_done = 0 start_time = time.monotonic() @@ -504,7 +505,7 @@ async def _job_prewarm_cities(): async with sem: await _fetch_and_store_tile(poi_type, x, y) total_fetched += 1 - await asyncio.sleep(1.5) + await asyncio.sleep(5) async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""): if not ADMIN: diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 610cd46..58fcff0 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -695,6 +695,7 @@ html.modal-open { width: 100%; max-width: 480px; max-height: 90vh; + max-height: 90svh; overflow: hidden; /* Modal selbst scrollt NICHT */ display: flex; flex-direction: column; @@ -1163,7 +1164,7 @@ html.modal-open { border-radius: 50%; border: none; background: rgba(0,0,0,.50); - color: rgba(255,255,255,.55); + color: #9ca3af; font-size: 14px; cursor: pointer; display: flex; @@ -2050,6 +2051,9 @@ html.modal-open { white-space: nowrap; flex-shrink: 0; } +.rk-rec-btn--active { + animation: rec-pulse 1.2s ease-in-out infinite; +} .rk-filters { display: flex; flex-direction: column; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4605477..f65326f 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 = '187'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '202'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -357,12 +357,14 @@ const App = (() => { title: 'Was möchtest du hinzufügen?', body: `
- ${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')} - ${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')} + ${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')} + ${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')} + ${authBtn('chat', 'btn-secondary', 'chat-circle-dots','Neue Nachricht')} + ${authBtn('forum', 'btn-secondary', 'chats', 'Forenbeitrag erstellen')} - ${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')} + ${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')}
${!loggedIn ? `

@@ -386,6 +388,8 @@ const App = (() => { if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } + if (action === 'chat') { navigate('chat'); setTimeout(() => pages['chat'].module?._showNewMessagePicker?.(), 400); } + if (action === 'forum') { navigate('forum'); setTimeout(() => pages['forum'].module?.openNew?.(), 400); } }, 350); }, { once: true }); } diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 4a15929..df55f65 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -104,6 +104,20 @@ window.Page_admin = (() => { ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} + ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} + ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')} + + +

+

OSM-Cache nach Typ

+
+ ${Object.entries(s.osm_by_type).map(([type, count]) => ` +
+ ${type} + ${count.toLocaleString('de')} +
+ `).join('')} +
@@ -543,9 +557,46 @@ window.Page_admin = (() => {
Lade…
+
+
+ Server-Logs + + +
+
Lade…
+
`; - el.querySelector('#adm-sys-refresh').addEventListener('click', () => _loadSystemCards(el.querySelector('#adm-sys-cards'))); + const loadLogs = async () => { + const level = el.querySelector('#adm-log-level').value; + const box = el.querySelector('#adm-log-box'); + box.textContent = 'Lade…'; + const rows = await API.get(`/admin/logs?lines=200${level ? '&level=' + level : ''}`); + const COLORS = { ERROR: '#ef4444', WARNING: '#f59e0b', INFO: '#6b7280', DEBUG: '#94a3b8' }; + box.innerHTML = rows.reverse().map(r => { + const color = COLORS[r.l] || '#6b7280'; + return `
` + + `${r.t} ` + + `${r.l} ` + + `${_esc(r.n)} ` + + `${_esc(r.m)}
`; + }).join('') || 'Keine Einträge'; + }; + el.querySelector('#adm-sys-refresh').addEventListener('click', () => { + _loadSystemCards(el.querySelector('#adm-sys-cards')); + loadLogs(); + }); + el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs); + el.querySelector('#adm-log-level').addEventListener('change', loadLogs); await _loadSystemCards(el.querySelector('#adm-sys-cards')); + await loadLogs(); } async function _loadSystemCards(el) { diff --git a/backend/static/js/pages/chat.js b/backend/static/js/pages/chat.js index 634d27d..32dc179 100644 --- a/backend/static/js/pages/chat.js +++ b/backend/static/js/pages/chat.js @@ -41,13 +41,18 @@ window.Page_chat = (() => { _container.innerHTML = `
-
-

Nachrichten

+
+

Nachrichten

+
`; + document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker); await _loadList(); await _updateChatBadge(); } @@ -396,6 +401,51 @@ window.Page_chat = (() => { .replace(/\n/g, '
'); } + // ---------------------------------------------------------- + // Neue Nachricht — Freundesliste als Picker + // ---------------------------------------------------------- + async function _showNewMessagePicker() { + let friends = []; + try { friends = (await API.friends.list()).friends || []; } catch {} + + if (!friends.length) { + UI.toast.info('Du hast noch keine Freunde. Gehe zu Freunde um jemanden hinzuzufügen.'); + return; + } + + const items = friends.map(f => ` + `).join(''); + + UI.modal.open({ + title: 'Neue Nachricht', + body: `
${items}
`, + footer: ``, + }); + + document.getElementById('chat-picker-cancel')?.addEventListener('click', UI.modal.close); + document.querySelectorAll('[data-uid]').forEach(btn => { + btn.addEventListener('click', async () => { + UI.modal.close(); + const uid = parseInt(btn.dataset.uid); + try { + const { conversation_id } = await API.chat.start(uid); + await _openThread(conversation_id); + } catch (e) { UI.toast.error(e.message); } + }); + }); + } + // ---------------------------------------------------------- return { init, diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 38b4fc0..e704820 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -53,6 +53,7 @@ window.Page_diary = (() => { meilenstein:{ label: 'Meilenstein',icon: '' }, training: { label: 'Training', icon: '' }, gesundheit: { label: 'Gesundheit', icon: '' }, + spaziergang:{ label: 'Spaziergang', icon: '' }, ausflug: { label: 'Ausflug', icon: '' }, }; @@ -302,7 +303,7 @@ window.Page_diary = (() => { const typ = TYPEN[e.typ] || TYPEN.eintrag; const isMile = e.is_milestone || e.typ === 'meilenstein'; const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; - const tags = (e.tags || []).slice(0, 4); + const tags = (e.tags || []).filter(t => t && t.trim()).slice(0, 4); const allMedia = _allMedia(e); const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null; @@ -367,6 +368,18 @@ window.Page_diary = (() => { return `
${avatars}
`; } + // ---------------------------------------------------------- + // LIGHTBOX + // ---------------------------------------------------------- + function _showLightbox(src) { + const lb = document.createElement('div'); + lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out'; + lb.innerHTML = ` + `; + lb.addEventListener('click', () => lb.remove()); + document.body.appendChild(lb); + } + // ---------------------------------------------------------- // DETAIL-ANSICHT // ---------------------------------------------------------- @@ -376,7 +389,7 @@ window.Page_diary = (() => { const typ = TYPEN[entry.typ] || TYPEN.eintrag; const isMile = entry.is_milestone || entry.typ === 'meilenstein'; - const tags = (entry.tags || []); + const tags = (entry.tags || []).filter(t => t && t.trim()); const allMedia = _allMedia(entry); const photo = allMedia.length > 0 @@ -394,7 +407,8 @@ window.Page_diary = (() => { class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}" data-media-id="${m.id}" aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}" - title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}">⭐ + title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}" + style="background:${m.is_cover ? '#f5c518' : 'rgba(0,0,0,.45)'};color:${m.is_cover ? '#fff' : 'rgba(255,255,255,.7)'}">
`).join('')} `) : ''; @@ -417,7 +431,6 @@ window.Page_diary = (() => { const body = ` ${isMile ? `
${UI.icon('trophy')} Meilenstein
` : ''} - ${photo}
${typ.icon} ${typ.label} @@ -425,24 +438,31 @@ window.Page_diary = (() => {
${entry.location_name ? ` -
+
${entry.gps_lat ? `${UI.escape(entry.location_name)}` : UI.escape(entry.location_name)}
` : ''} - ${dogsHtml} ${entry.text - ? `

${UI.escape(entry.text)}

` + ? `

${UI.escape(_cleanText(entry.text))}

` : ''} ${tags.length - ? `
+ ? `
${tags.map(t => `${t}`).join('')}
` : ''} - + ${dogsHtml} + ${photo} + `; UI.modal.open({ title: entry.titel || typ.label, body }); + // Bilder anklickbar machen (Lightbox) + document.querySelector('#modal-container .modal-body')?.querySelectorAll('img').forEach(img => { + img.style.cursor = 'zoom-in'; + img.addEventListener('click', () => _showLightbox(img.src)); + }); + // Stern-Buttons: Cover-Bild setzen document.querySelectorAll('.diary-cover-btn').forEach(btn => { btn.addEventListener('click', async (ev) => { @@ -460,8 +480,12 @@ window.Page_diary = (() => { document.querySelectorAll('.diary-cover-btn').forEach(b => { const active = parseInt(b.dataset.mediaId) === mediaId; b.classList.toggle('diary-cover-btn--active', active); + b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)'; + b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)'; b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen'); b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen'); + const use = b.querySelector('use'); + if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`); }); UI.toast.success('Cover-Bild gesetzt.'); } catch { @@ -593,25 +617,14 @@ window.Page_diary = (() => { - - - + + - -
- - - -
+ +
`; @@ -637,7 +650,6 @@ window.Page_diary = (() => { // ---- Multi-Media-Verwaltung ---- const mediaInput = document.getElementById('diary-media-input'); - const cameraInput = document.getElementById('diary-camera-input'); // Neue Dateien die noch nicht hochgeladen wurden const _newFiles = []; @@ -646,16 +658,17 @@ window.Page_diary = (() => { const grid = document.getElementById('diary-new-media-grid'); if (!grid) return; if (_newFiles.length === 0) { grid.style.display = 'none'; grid.innerHTML = ''; return; } - grid.style.display = ''; + grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px'; grid.innerHTML = _newFiles.map((f, i) => { const objUrl = URL.createObjectURL(f); const thumb = f.type.startsWith('video/') ? `` : ``; - return `
+ return `
${thumb} + aria-label="Entfernen" + style="position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:50%;border:none;background:rgba(0,0,0,.55);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px">✕
`; }).join(''); grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => { @@ -673,22 +686,24 @@ window.Page_diary = (() => { if (!wrap) return; const items = isEdit ? _allMedia(entry) : []; if (items.length === 0) { wrap.innerHTML = ''; return; } - const grid = `
- ${items.map(m => ` -
+ const GRID_STYLE = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px'; + const grid = `
+ ${items.map((m, idx) => ` +
${m.media_type === 'video' - ? `` - : ``} - ${m.id != null - ? ` - ` - : ``} + ? `` + : ``} + + ${m.id != null ? ` + ` : ''}
`).join('')}
`; wrap.innerHTML = grid; @@ -730,8 +745,12 @@ window.Page_diary = (() => { wrap.querySelectorAll('.diary-cover-btn--form').forEach(b => { const active = parseInt(b.dataset.mediaId) === mediaId; b.classList.toggle('diary-cover-btn--active', active); + b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)'; + b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)'; b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen'); b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen'); + const use = b.querySelector('use'); + if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`); }); UI.toast.success('Cover-Bild gesetzt.'); } catch { @@ -760,12 +779,6 @@ window.Page_diary = (() => { tmp.click(); } - cameraInput?.addEventListener('change', () => { - if (cameraInput.files.length) { - _addFiles(cameraInput.files); - cameraInput.value = ''; - } - }); mediaInput?.addEventListener('change', () => { if (mediaInput.files.length) { _addFiles(mediaInput.files); @@ -773,10 +786,6 @@ window.Page_diary = (() => { } }); - document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click()); - document.getElementById('diary-btn-library')?.addEventListener('click', () => _openPicker({})); - document.getElementById('diary-btn-file') ?.addEventListener('click', () => _openPicker({ noAccept: true })); - document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); // Milestone-Toggle @@ -1168,6 +1177,16 @@ window.Page_diary = (() => { // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- + function _cleanText(text) { + if (!text) return text; + return text + .replace(/!\[([^\]]*)\]\([^\)]*\)/g, '') // Markdown-Bilder ![]() + .replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1') // Markdown-Links [text](url) → text + .replace(/^\[\]\s*$/gm, '') // leere [] auf eigener Zeile + .replace(/\n{3,}/g, '\n\n') // mehrfache Leerzeilen kürzen + .trim(); + } + return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 5ec8616..641e7dd 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -27,6 +27,11 @@ window.Page_forum = (() => { { key: 'region', label: 'Region' }, { key: 'gesundheit', label: 'Gesundheit' }, { key: 'erziehung', label: 'Erziehung' }, + { key: 'spaziergang', label: 'Spaziergang' }, + { key: 'ausflug', label: 'Ausflug' }, + { key: 'training', label: 'Training & Lektionen' }, + { key: 'ernaehrung', label: 'Ernährung & Rezepte' }, + { key: 'probleme', label: 'Probleme' }, { key: 'tauschboerse', label: 'Tauschbörse' }, ]; @@ -463,9 +468,7 @@ window.Page_forum = (() => { // Foto-Vollbild document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => { - img.addEventListener('click', () => { - window.open(img.dataset.src || img.src, '_blank'); - }); + img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src)); }); // Reply file preview @@ -515,6 +518,7 @@ window.Page_forum = (() => { if (placeholder) listEl.innerHTML = ''; listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod)); _bindPostActions(listEl, thread.id, uid, isMod); + listEl.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } document.getElementById('forum-reply-text').value = ''; const previews = document.getElementById('forum-reply-previews'); @@ -620,7 +624,7 @@ window.Page_forum = (() => { // Foto-Fullscreen container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => { img.dataset.bound = '1'; - img.addEventListener('click', () => window.open(img.dataset.src || img.src, '_blank')); + img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src)); }); } @@ -755,6 +759,10 @@ window.Page_forum = (() => {
+
+ +
+
@@ -776,6 +784,11 @@ window.Page_forum = (() => { UI.modal.open({ title: '+ Neues Thema', body, footer }); + let _picker = null; + setTimeout(() => { + _picker = UI.locationPicker({ containerId: 'forum-location-picker' }); + }, 50); + document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('ff-rules-link')?.addEventListener('click', _showRules); @@ -803,10 +816,15 @@ window.Page_forum = (() => { } await UI.asyncButton(btn, async () => { + const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null }; + const created = await API.forum.create({ - kategorie: fd.kategorie, - titel: (fd.titel || '').trim(), - text: (fd.text || '').trim(), + kategorie: fd.kategorie, + titel: (fd.titel || '').trim(), + text: (fd.text || '').trim(), + thread_lat: loc.lat ?? null, + thread_lon: loc.lon ?? null, + thread_ort: loc.name ?? null, }); // Fotos hochladen @@ -824,7 +842,8 @@ window.Page_forum = (() => { }); UI.modal.close(); _renderList(); - UI.toast.success('Thema erstellt!'); + UI.toast.success('Beitrag erstellt!'); + document.getElementById('forum-thread-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); } @@ -888,7 +907,16 @@ window.Page_forum = (() => { try { const members = await API.forum.membersMap(); members.forEach(m => { - L.marker([m.lat, m.lon]) + const icon = L.divIcon({ + className: '', + html: `
${_esc((m.vorname||'?')[0].toUpperCase())}
`, + iconSize: [32, 32], iconAnchor: [16, 16], + }); + L.marker([m.lat, m.lon], { icon }) .bindPopup(`${_esc(m.vorname || '?')}`) .addTo(_map); }); @@ -967,6 +995,20 @@ window.Page_forum = (() => { }); } - return { init, refresh, onDogChange }; + function openNew() { + if (!_appState?.user) { UI.toast.info('Bitte erst anmelden.'); return; } + _showCreateForm(); + } + + function _showLightbox(src) { + const lb = document.createElement('div'); + lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out'; + lb.innerHTML = ` + `; + lb.addEventListener('click', () => lb.remove()); + document.body.appendChild(lb); + } + + return { init, refresh, onDogChange, openNew }; })(); diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index ba537ca..3840e7a 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -196,36 +196,56 @@ window.Page_friends = (() => { } } + let _activityFilter = 'alle'; + let _activityAll = []; + function _renderActivity(items) { + _activityAll = items; const el = _container.querySelector('#fr-activity'); if (!el) return; - if (!items.length) { - el.innerHTML = ` -
- -
- -

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

-
-
- `; - return; - } + const FILTERS = [ + { key: 'alle', label: 'Alle' }, + { key: 'diary', label: 'Tagebuch' }, + { key: 'walk', label: 'Gassi-Treffen' }, + { key: 'health', label: 'Gesundheit' }, + { key: 'new_dog', label: 'Neuer Hund' }, + ]; + + const chips = FILTERS.map(f => ` + + `).join(''); + + const filtered = _activityFilter === 'alle' + ? items + : items.filter(i => i.type === _activityFilter); el.innerHTML = `
-
- ${items.map(item => _activityItem(item)).join('')} +
+ ${chips}
+ ${!filtered.length + ? `
+

+ Keine Einträge in dieser Kategorie. +

+
` + : `
+ ${filtered.map(item => _activityItem(item)).join('')} +
` + }
`; + + el.querySelectorAll('[data-af]').forEach(btn => { + btn.addEventListener('click', () => { + _activityFilter = btn.dataset.af; + _renderActivity(_activityAll); + }); + }); } function _activityItem(item) { diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 954be6c..43d8c8c 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -678,11 +678,21 @@ window.Page_map = (() => { return pois.length; } catch { _done++; + const pct = Math.round(20 + _done / _total * 80); + const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; + _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); return _layers[layerKey].filter(m => !m._ownPlace).length; } }); await Promise.all(freshTasks); _overpassActive = false; + + // Hinweis wenn Marker vorhanden aber alle Layer deaktiviert + const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length; + const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false); + if (totalLoaded > 0 && allHidden) { + _setOsmStatus('Layer deaktiviert — Liste antippen', 100); + } } // ---------------------------------------------------------- @@ -1506,6 +1516,6 @@ window.Page_map = (() => { }); } - return { init, refresh, onDogChange, startRecording: _startRecording }; + return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })(); diff --git a/backend/static/js/pages/notifications.js b/backend/static/js/pages/notifications.js index ab9824e..6cf90bb 100644 --- a/backend/static/js/pages/notifications.js +++ b/backend/static/js/pages/notifications.js @@ -33,14 +33,37 @@ window.Page_notifications = (() => { } } + /** Parst n.data sicher — egal ob String oder Objekt */ + function _parseData(raw) { + if (!raw) return {}; + if (typeof raw === 'object') return raw; + try { return JSON.parse(raw); } catch (_) { return {}; } + } + + /** Ermittelt Ziel-Seite und optionale Label für den Toast */ + function _navTarget(n) { + const d = _parseData(n.data); + // Explizit gesetzte Zielseite hat Vorrang + if (d.page) return { page: d.page, label: d.page }; + // Typ-basiertes Fallback + switch (n.type) { + case 'chat_message': return { page: d.conversation_id ? `chat?id=${d.conversation_id}` : 'chat', label: 'Chat' }; + case 'friend_request': return { page: 'friends', label: 'Freunde' }; + case 'milestone': return { page: 'diary', label: 'Tagebuch' }; + case 'poison_alert': return { page: 'map', label: 'Karte' }; + default: return { page: '', label: '' }; + } + } + /** Rendert eine einzelne Notification als HTML-String */ function _renderItem(n) { - const unread = !n.read_at; + const unread = !n.read_at; const iconName = unread ? _iconForType(n.type) : 'bell'; - const cls = ['notif-item', unread ? 'notif-unread' : ''].filter(Boolean).join(' '); + const cls = ['notif-item', unread ? 'notif-unread' : ''].filter(Boolean).join(' '); + const nav = _navTarget(n); return ` -
+
${UI.icon(iconName)}
${UI.escape(n.title)}
@@ -118,16 +141,24 @@ window.Page_notifications = (() => { // Löschen-Button nicht doppelt behandeln if (e.target.closest('.notif-del-btn')) return; - const id = parseInt(el.dataset.id, 10); - const page = el.dataset.page; + const id = parseInt(el.dataset.id, 10); + const page = el.dataset.page; + const navLabel = el.dataset.navLabel; - // Optisch sofort als gelesen markieren + // Sofortiges visuelles Feedback: als gelesen markieren el.classList.remove('notif-unread'); + el.style.opacity = '0.6'; - try { await API.notifications.read(id); } catch (_) {} + // API-Call im Hintergrund — nicht abwarten + API.notifications.read(id).catch(() => {}); if (page && window.App?.navigate) { - window.App.navigate(page); + if (navLabel) UI.toast?.(`Öffne ${navLabel}…`, 'info'); + // Kurze Pause damit der Toast sichtbar wird, dann navigieren + setTimeout(() => window.App.navigate(page), 150); + } else { + // Keine Zielseite — Opacity nach kurzer Zeit zurücksetzen + setTimeout(() => { el.style.opacity = ''; }, 800); } }); }); @@ -136,10 +167,19 @@ window.Page_notifications = (() => { list.querySelectorAll('.notif-del-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); - const id = parseInt(btn.dataset.del, 10); + const id = parseInt(btn.dataset.del, 10); + const item = btn.closest('.notif-item'); + + // Sofortiges visuelles Feedback + if (item) { + item.style.transition = 'opacity 0.2s, transform 0.2s'; + item.style.opacity = '0'; + item.style.transform = 'translateX(20px)'; + } + try { await API.notifications.delete(id); - btn.closest('.notif-item')?.remove(); + item?.remove(); if (!list.querySelector('.notif-item')) { list.innerHTML = `
@@ -147,7 +187,14 @@ window.Page_notifications = (() => {

Keine Benachrichtigungen

`; } - } catch (_) {} + } catch (_) { + // Rückgängig machen falls Fehler + if (item) { + item.style.opacity = ''; + item.style.transform = ''; + } + UI.toast?.('Löschen fehlgeschlagen.', 'error'); + } }); }); } @@ -215,19 +262,22 @@ window.Page_notifications = (() => { .notif-del-btn { flex-shrink: 0; color: var(--c-text-muted); - opacity: 0; - transition: opacity var(--transition-fast); + opacity: 0.45; + transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast); min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-sm); } - .notif-item:hover .notif-del-btn { + .notif-item:hover .notif-del-btn, + .notif-del-btn:focus-visible { opacity: 1; + color: var(--c-danger, #e53e3e); } - @media (hover: none) { - .notif-del-btn { opacity: 1; } + .notif-del-btn:active { + background: var(--c-danger-subtle, rgba(229,62,62,.12)); } `; document.head.appendChild(style); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 89284cd..71f4d8d 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -17,6 +17,8 @@ window.Page_routes = (() => { let _sortBy = 'newest'; let _onlyMine = false; + let _isRecording = false; + // 'mine' | 'discover' let _browseMode = 'mine'; @@ -57,7 +59,7 @@ window.Page_routes = (() => { } } - function refresh() { _loadData(); } + function refresh() { _syncRecBtn(); _loadData(); } function onDogChange() {} // ---------------------------------------------------------- @@ -132,8 +134,16 @@ window.Page_routes = (() => { document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list')); document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map')); document.getElementById('rk-rec-btn').addEventListener('click', () => { - App.navigate('map'); - setTimeout(() => window.Page_map?.startRecording?.(), 600); + if (_isRecording) { + _isRecording = false; + _syncRecBtn(); + window.Page_map?.stopRecording?.(); + } else { + _isRecording = true; + _syncRecBtn(); + App.navigate('map'); + setTimeout(() => window.Page_map?.startRecording?.(), 600); + } }); document.getElementById('rk-import-input').addEventListener('change', e => { const file = e.target.files?.[0]; @@ -159,6 +169,22 @@ window.Page_routes = (() => { document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover')); } + function _syncRecBtn() { + // Falls Page_map bereits initialisiert ist, echten State abfragen + if (window.Page_map?.isRecording) { + _isRecording = window.Page_map.isRecording(); + } + const btn = document.getElementById('rk-rec-btn'); + if (!btn) return; + if (_isRecording) { + btn.className = 'btn btn-danger btn-sm rk-rec-btn rk-rec-btn--active'; + btn.innerHTML = UI.icon('stop-circle') + ' Stopp'; + } else { + btn.className = 'btn btn-primary btn-sm rk-rec-btn'; + btn.innerHTML = UI.icon('path') + ' Aufzeichnen'; + } + } + function _setBrowseMode(mode) { _browseMode = mode; document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine'); diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js index 729942e..c825fa0 100644 --- a/backend/static/js/pages/sitting.js +++ b/backend/static/js/pages/sitting.js @@ -262,6 +262,7 @@ window.Page_sitting = (() => {
${s.max_hunde} max. Hund${s.max_hunde !== 1 ? 'e' : ''}
${s.radius_km} km Umkreis
+
`; const footer = _state.user && _mySitter?.user_id !== s.user_id ? ` @@ -270,6 +271,13 @@ window.Page_sitting = (() => { UI.modal.open({ title: 'Sitter-Profil', body, footer }); + UI.ratingStars({ + containerId: `sit-rating-${s.id}`, + targetType: 'sitting', + targetId: s.id, + isLoggedIn: !!_state.user, + }); + document.getElementById('sit-anfrage-btn')?.addEventListener('click', () => { UI.modal.close(); setTimeout(() => _openAnfrageForm(s), 50); @@ -353,7 +361,7 @@ window.Page_sitting = (() => {
- +
@@ -374,17 +382,10 @@ window.Page_sitting = (() => { `).join('')}
-
-
- - -
-
- - -
+
+ +
-
@@ -408,13 +409,17 @@ window.Page_sitting = (() => { `; UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer }); - document.getElementById('sit-gps-btn')?.addEventListener('click', async () => { - try { - const pos = await API.getLocation(); - document.getElementById('sit-lat').value = pos.lat.toFixed(6); - document.getElementById('sit-lon').value = pos.lon.toFixed(6); - } catch { UI.toast('GPS nicht verfügbar.', 'error'); } - }); + // Location Picker initialisieren + let _picker = null; + setTimeout(() => { + _picker = UI.locationPicker({ + containerId: 'sit-loc-picker', + onSelect(lat, lon, name) { /* State wird über picker.getValue() ausgelesen */ }, + }); + if (s?.lat && s?.lon) { + _picker.setValue(s.lat, s.lon, s.ort_name || null); + } + }, 50); const form = document.getElementById(id); const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]'); @@ -423,13 +428,14 @@ window.Page_sitting = (() => { e.preventDefault(); const fd = new FormData(form); const svcs = [...form.querySelectorAll('[name="services"]:checked')].map(cb => cb.value); + const loc = _picker ? _picker.getValue() : { lat: s?.lat || null, lon: s?.lon || null, name: null }; const data = { beschreibung: fd.get('beschreibung') || null, preis_pro_tag: parseFloat(fd.get('preis_pro_tag')) || 0, max_hunde: parseInt(fd.get('max_hunde')) || 1, services: svcs, - lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null, - lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null, + lat: loc.lat, + lon: loc.lon, radius_km: parseInt(fd.get('radius_km')) || 20, }; if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0; diff --git a/backend/static/sw.js b/backend/static/sw.js index 7242f59..a76fde9 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-v213'; +const CACHE_VERSION = 'by-v225'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/docker-compose.yml b/docker-compose.yml index 851b11e..a3d6772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: banyaro: build: . container_name: banyaro - restart: unless-stopped + restart: on-failure:5 ports: - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter volumes: @@ -16,7 +16,7 @@ services: - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/"] + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s timeout: 10s retries: 3