Feature: Ratings, Lightbox, Forum-Standort, Notifications, Routen-Recording, Chat-Picker

- 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
This commit is contained in:
rene 2026-04-19 09:40:35 +02:00
parent aa70a838f2
commit e56183b642
21 changed files with 648 additions and 175 deletions

View file

@ -482,6 +482,10 @@ def _migrate(conn_factory):
("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"), ("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"),
# Tagebuch-Medien: Cover-Bild markieren # Tagebuch-Medien: Cover-Bild markieren
("diary_media", "is_cover", "INTEGER NOT NULL DEFAULT 0"), ("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: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:

View file

@ -4,6 +4,7 @@ BAN YARO — FastAPI Hauptanwendung
import os import os
import logging import logging
from collections import deque
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
@ -13,10 +14,25 @@ from database import init_db
import ki import ki
import scheduler as sched 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( logging.basicConfig(
level = logging.INFO, level = logging.INFO,
format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s", format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
logging.getLogger().addHandler(_BufferHandler())
logger = logging.getLogger(__name__) 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.widget import router as widget_router
from routes.notifications import router as notifications_router from routes.notifications import router as notifications_router
from routes.services import router as services_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(widget_router, prefix="/api/widget", tags=["Widget"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(services_router, prefix="/api/services", tags=["Services"]) app.include_router(services_router, prefix="/api/services", tags=["Services"])
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -116,6 +116,14 @@ async def stats(user=Depends(require_mod)):
media_count = media_diary + media_health media_count = media_diary + media_health
routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0] routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0]
events_total = conn.execute("SELECT COUNT(*) FROM events").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 { return {
"users_total": users_total, "users_total": users_total,
@ -131,6 +139,9 @@ async def stats(user=Depends(require_mod)):
"media_count": media_count, "media_count": media_count,
"routes_total": routes_total, "routes_total": routes_total,
"events_total": events_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 # GET /api/admin/audit
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -15,7 +15,8 @@ router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
FORUM_DIR = os.path.join(MEDIA_DIR, "forum") 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']
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -25,6 +26,9 @@ class ThreadCreate(BaseModel):
kategorie: str = 'allgemein' kategorie: str = 'allgemein'
titel: str titel: str
text: str text: str
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = None
class PostCreate(BaseModel): class PostCreate(BaseModel):
text: str text: str
@ -143,9 +147,10 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
raise HTTPException(400, "Ungültige Kategorie.") raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn: with db() as conn:
cur = conn.execute( cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text) """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort)
VALUES (?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip()) (user['id'], data.kategorie, data.titel.strip(), data.text.strip(),
data.thread_lat, data.thread_lon, data.thread_ort)
) )
row = conn.execute( row = conn.execute(
"""SELECT t.*, u.name AS autor_name """SELECT t.*, u.name AS autor_name
@ -523,7 +528,8 @@ async def members_map():
AND forum_lat IS NOT NULL AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL""" AND forum_lon IS NOT NULL"""
).fetchall() ).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]
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -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 dogs d WHERE d.user_id=u.id AND d.is_public=1) AS dogs_json
FROM users u FROM users u
WHERE u.id != ? WHERE u.id != ?
AND u.name LIKE ? AND norm(u.name) LIKE norm(?)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM friendships f SELECT 1 FROM friendships f
WHERE (f.requester_id=? AND f.addressee_id=u.id) 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) (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: try:
from routes.push import send_push_to_user from routes.push import send_push_to_user
send_push_to_user(target_id, { 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 JOIN users u ON u.id = d.user_id
WHERE d.user_id IN ({ph}) WHERE d.user_id IN ({ph})
ORDER BY dg.created_at DESC ORDER BY dg.created_at DESC
LIMIT 30 LIMIT 15
""", friend_ids).fetchall() """, friend_ids).fetchall()
# Gesundheitseinträge der Freunde (nur Typ + Datum, kein Inhalt) # 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 JOIN users u ON u.id = d.user_id
WHERE d.user_id IN ({ph}) WHERE d.user_id IN ({ph})
ORDER BY h.created_at DESC ORDER BY h.created_at DESC
LIMIT 30 LIMIT 15
""", friend_ids).fetchall() """, friend_ids).fetchall()
# Gassi-Treffen der Freunde # 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 JOIN users u ON u.id = w.user_id
WHERE w.user_id IN ({ph}) WHERE w.user_id IN ({ph})
ORDER BY w.created_at DESC ORDER BY w.created_at DESC
LIMIT 30 LIMIT 15
""", friend_ids).fetchall() """, friend_ids).fetchall()
# Neue Hunde (angelegt in den letzten 30 Tagen) # 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}) WHERE d.user_id IN ({ph})
AND d.created_at >= datetime('now', '-30 days') AND d.created_at >= datetime('now', '-30 days')
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT 30 LIMIT 15
""", friend_ids).fetchall() """, friend_ids).fetchall()
_ICON = { _ICON = {
@ -309,7 +322,7 @@ async def get_activity(user=Depends(get_current_user)):
# Zusammenführen und nach created_at absteigend sortieren, max. 30 # Zusammenführen und nach created_at absteigend sortieren, max. 30
items.sort(key=lambda x: x["created_at"] or "", reverse=True) items.sort(key=lambda x: x["created_at"] or "", reverse=True)
return items[:30] return items[:50]
@router.delete("/{friend_user_id}") @router.delete("/{friend_user_id}")

View file

@ -20,6 +20,9 @@ CACHE_ZOOM = 12
CACHE_DAYS = 14 CACHE_DAYS = 14
OVERPASS_URL = 'https://overpass-api.de/api/interpreter' 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 = { OSM_QUERIES = {
'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;', '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;', '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 # Overpass-Fetch + Cache
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _fetch_overpass(query): async def _fetch_overpass(query):
for attempt in range(3):
async with _overpass_sem:
async with httpx.AsyncClient(timeout=40) as client: async with httpx.AsyncClient(timeout=40) as client:
r = await client.post(OVERPASS_URL, data={'data': query}) r = await client.post(OVERPASS_URL, data={'data': query})
if r.status_code != 429:
r.raise_for_status() r.raise_for_status()
return r.json().get('elements', []) 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): def _stale_tiles(poi_type, tiles):
stale = [] stale = []
@ -143,11 +153,7 @@ async def get_pois(
stale = _stale_tiles(type, tiles) stale = _stale_tiles(type, tiles)
if stale and not fast: if stale and not fast:
sem = asyncio.Semaphore(3) await asyncio.gather(*[_fetch_and_store_tile(type, x, y) for (x, y) in stale])
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])
fetched_fresh = True fetched_fresh = True
with db() as conn: with db() as conn:
@ -313,12 +319,8 @@ async def analyze_region(
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
async def _warmup(): 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 = [ tasks = [
_limited(pt, x, y) _fetch_and_store_tile(pt, x, y)
for pt in OSM_QUERIES for pt in OSM_QUERIES
for (x, y) in _stale_tiles(pt, tiles) for (x, y) in _stale_tiles(pt, tiles)
] ]

124
backend/routes/ratings.py Normal file
View file

@ -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)

View file

@ -58,7 +58,7 @@ def start():
) )
_scheduler.add_job( _scheduler.add_job(
_job_prewarm_cities, _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", id="prewarm_cities",
replace_existing=True, replace_existing=True,
misfire_grace_time=7200, misfire_grace_time=7200,
@ -71,14 +71,6 @@ def start():
id="import_events_startup", id="import_events_startup",
replace_existing=True, 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 # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen
_scheduler.add_job( _scheduler.add_job(
_job_seed_breeds, _job_seed_breeds,
@ -482,6 +474,15 @@ _CITIES_DE = [
(52.2763, 8.0479, "Osnabrück"), (52.2763, 8.0479, "Osnabrück"),
(53.8755, 10.7000, "Lübeck-Ost"), (53.8755, 10.7000, "Lübeck-Ost"),
(51.9333, 6.8667, "Borken"), (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(): async def _job_prewarm_cities():
@ -493,7 +494,7 @@ async def _job_prewarm_cities():
REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden
logger.info("City-Prewarm Job startet…") logger.info("City-Prewarm Job startet…")
sem = asyncio.Semaphore(2) sem = asyncio.Semaphore(1)
total_fetched = 0 total_fetched = 0
cities_done = 0 cities_done = 0
start_time = time.monotonic() start_time = time.monotonic()
@ -504,7 +505,7 @@ async def _job_prewarm_cities():
async with sem: async with sem:
await _fetch_and_store_tile(poi_type, x, y) await _fetch_and_store_tile(poi_type, x, y)
total_fetched += 1 total_fetched += 1
await asyncio.sleep(1.5) await asyncio.sleep(5)
async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""): async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""):
if not ADMIN: if not ADMIN:

View file

@ -695,6 +695,7 @@ html.modal-open {
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
max-height: 90vh; max-height: 90vh;
max-height: 90svh;
overflow: hidden; /* Modal selbst scrollt NICHT */ overflow: hidden; /* Modal selbst scrollt NICHT */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1163,7 +1164,7 @@ html.modal-open {
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: rgba(0,0,0,.50); background: rgba(0,0,0,.50);
color: rgba(255,255,255,.55); color: #9ca3af;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -2050,6 +2051,9 @@ html.modal-open {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.rk-rec-btn--active {
animation: rec-pulse 1.2s ease-in-out infinite;
}
.rk-filters { .rk-filters {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {
@ -359,6 +359,8 @@ const App = (() => {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')} ${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')}
${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')} ${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')}
${authBtn('chat', 'btn-secondary', 'chat-circle-dots','Neue Nachricht')}
${authBtn('forum', 'btn-secondary', 'chats', 'Forenbeitrag erstellen')}
<button class="btn btn-danger w-full" data-quick="poison"> <button class="btn btn-danger w-full" data-quick="poison">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button> </button>
@ -386,6 +388,8 @@ const App = (() => {
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].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); }, 350);
}, { once: true }); }, { once: true });
} }

View file

@ -104,6 +104,20 @@ window.Page_admin = (() => {
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, '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('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)')}
</div>
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">OSM-Cache nach Typ</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${Object.entries(s.osm_by_type).map(([type, count]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span style="color:var(--c-text-secondary)">${type}</span>
<span style="font-weight:600">${count.toLocaleString('de')}</span>
</div>
`).join('')}
</div>
</div> </div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
@ -543,9 +557,46 @@ window.Page_admin = (() => {
</button> </button>
</div> </div>
<div id="adm-sys-cards">Lade</div> <div id="adm-sys-cards">Lade</div>
<div style="margin-top:var(--space-5)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
<select id="adm-log-level" class="input" style="width:auto;padding:2px 8px;font-size:var(--text-xs)">
<option value="">Alle</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<button class="btn btn-ghost btn-sm" id="adm-log-refresh">${UI.icon('arrows-clockwise')}</button>
</div>
<div id="adm-log-box" style="
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-family:monospace;font-size:11px;
max-height:420px;overflow-y:auto;line-height:1.6">Lade</div>
</div>
`; `;
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 `<div style="border-bottom:1px solid var(--c-border);padding:2px 0">` +
`<span style="color:var(--c-text-muted)">${r.t}</span> ` +
`<span style="color:${color};font-weight:600">${r.l}</span> ` +
`<span style="color:var(--c-text-secondary)">${_esc(r.n)}</span> ` +
`<span>${_esc(r.m)}</span></div>`;
}).join('') || '<span style="color:var(--c-text-muted)">Keine Einträge</span>';
};
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 _loadSystemCards(el.querySelector('#adm-sys-cards'));
await loadLogs();
} }
async function _loadSystemCards(el) { async function _loadSystemCards(el) {

View file

@ -41,13 +41,18 @@ window.Page_chat = (() => {
_container.innerHTML = ` _container.innerHTML = `
<div style="background:var(--c-surface)"> <div style="background:var(--c-surface)">
<div style="padding:var(--space-4) var(--space-4) var(--space-2)"> <div style="display:flex;align-items:center;justify-content:space-between;
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold)">Nachrichten</h2> padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div> </div>
<div id="chat-list-body"></div> <div id="chat-list-body"></div>
</div> </div>
`; `;
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
await _loadList(); await _loadList();
await _updateChatBadge(); await _updateChatBadge();
} }
@ -396,6 +401,51 @@ window.Page_chat = (() => {
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
} }
// ----------------------------------------------------------
// 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 => `
<button class="btn btn-ghost" data-uid="${f.friend_id}" style="
display:flex;align-items:center;gap:var(--space-3);
width:100%;text-align:left;padding:var(--space-3)">
<div style="width:40px;height:40px;border-radius:50%;
background:var(--c-primary-subtle);display:flex;align-items:center;
justify-content:center;font-weight:600;flex-shrink:0">
${f.avatar_url
? `<img src="${UI.escape(f.avatar_url)}" style="width:40px;height:40px;border-radius:50%;object-fit:cover">`
: UI.escape((f.friend_name||'?')[0].toUpperCase())}
</div>
<span>${UI.escape(f.friend_name || '—')}</span>
</button>`).join('');
UI.modal.open({
title: 'Neue Nachricht',
body: `<div style="display:flex;flex-direction:column;gap:2px">${items}</div>`,
footer: `<button class="btn btn-ghost flex-1" id="chat-picker-cancel">Abbrechen</button>`,
});
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 { return {
init, init,

View file

@ -53,6 +53,7 @@ window.Page_diary = (() => {
meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' }, meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' },
training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' }, training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' },
gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' }, gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
spaziergang:{ label: 'Spaziergang', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>' },
ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' }, ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' },
}; };
@ -302,7 +303,7 @@ window.Page_diary = (() => {
const typ = TYPEN[e.typ] || TYPEN.eintrag; const typ = TYPEN[e.typ] || TYPEN.eintrag;
const isMile = e.is_milestone || e.typ === 'meilenstein'; const isMile = e.is_milestone || e.typ === 'meilenstein';
const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; 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 allMedia = _allMedia(e);
const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null; const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null;
@ -367,6 +368,18 @@ window.Page_diary = (() => {
return `<div class="diary-dog-row">${avatars}</div>`; return `<div class="diary-dog-row">${avatars}</div>`;
} }
// ----------------------------------------------------------
// 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 = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// DETAIL-ANSICHT // DETAIL-ANSICHT
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -376,7 +389,7 @@ window.Page_diary = (() => {
const typ = TYPEN[entry.typ] || TYPEN.eintrag; const typ = TYPEN[entry.typ] || TYPEN.eintrag;
const isMile = entry.is_milestone || entry.typ === 'meilenstein'; 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 allMedia = _allMedia(entry);
const photo = allMedia.length > 0 const photo = allMedia.length > 0
@ -394,7 +407,8 @@ window.Page_diary = (() => {
class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}" class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}" data-media-id="${m.id}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}" aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}">&#11088;</button> 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)'}"><svg style="width:16px;height:16px;display:block" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg></button>
</div>`).join('')} </div>`).join('')}
</div>`) </div>`)
: ''; : '';
@ -417,7 +431,6 @@ window.Page_diary = (() => {
const body = ` const body = `
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''} ${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
${photo}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span> <span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)"> <span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
@ -425,24 +438,31 @@ window.Page_diary = (() => {
</span> </span>
</div> </div>
${entry.location_name ? ` ${entry.location_name ? `
<div class="diary-detail-location"> <div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>` : UI.escape(entry.location_name)} ${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>` : UI.escape(entry.location_name)}
</div>` : ''} </div>` : ''}
${dogsHtml}
${entry.text ${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${UI.escape(entry.text)}</p>` ? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text);margin-bottom:var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''} : ''}
${tags.length ${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-3)"> ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-bottom:var(--space-4)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')} ${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>` </div>`
: ''} : ''}
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="detail-edit">Bearbeiten</button> ${dogsHtml}
${photo}
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-4)" id="detail-edit">Bearbeiten</button>
`; `;
UI.modal.open({ title: entry.titel || typ.label, body }); 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 // Stern-Buttons: Cover-Bild setzen
document.querySelectorAll('.diary-cover-btn').forEach(btn => { document.querySelectorAll('.diary-cover-btn').forEach(btn => {
btn.addEventListener('click', async (ev) => { btn.addEventListener('click', async (ev) => {
@ -460,8 +480,12 @@ window.Page_diary = (() => {
document.querySelectorAll('.diary-cover-btn').forEach(b => { document.querySelectorAll('.diary-cover-btn').forEach(b => {
const active = parseInt(b.dataset.mediaId) === mediaId; const active = parseInt(b.dataset.mediaId) === mediaId;
b.classList.toggle('diary-cover-btn--active', active); 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('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen');
b.setAttribute('title', 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.'); UI.toast.success('Cover-Bild gesetzt.');
} catch { } catch {
@ -593,25 +617,14 @@ window.Page_diary = (() => {
<!-- Neue Medien: Vorschau-Grid --> <!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div> <div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckte Inputs --> <!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none"> <input type="file" id="diary-media-input" accept="image/*,video/*" multiple style="display:none">
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
<!-- Auswahlbuttons immer sichtbar --> <!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<div class="diary-media-picker" style="margin-top:var(--space-2)"> <label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<button type="button" class="diary-media-pick-btn" id="diary-btn-camera">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
Kamera
</button>
<button type="button" class="diary-media-pick-btn" id="diary-btn-library">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
Mediathek Fotos / Videos hinzufügen
</button> </label>
<button type="button" class="diary-media-pick-btn" id="diary-btn-file">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#folder-open"></use></svg>
Datei
</button>
</div>
</div> </div>
</form> </form>
`; `;
@ -637,7 +650,6 @@ window.Page_diary = (() => {
// ---- Multi-Media-Verwaltung ---- // ---- Multi-Media-Verwaltung ----
const mediaInput = document.getElementById('diary-media-input'); const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input');
// Neue Dateien die noch nicht hochgeladen wurden // Neue Dateien die noch nicht hochgeladen wurden
const _newFiles = []; const _newFiles = [];
@ -646,16 +658,17 @@ window.Page_diary = (() => {
const grid = document.getElementById('diary-new-media-grid'); const grid = document.getElementById('diary-new-media-grid');
if (!grid) return; if (!grid) return;
if (_newFiles.length === 0) { grid.style.display = 'none'; grid.innerHTML = ''; 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) => { grid.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f); const objUrl = URL.createObjectURL(f);
const thumb = f.type.startsWith('video/') const thumb = f.type.startsWith('video/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>` ? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`; : `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div class="diary-media-thumb-wrap" data-new-idx="${i}"> return `<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)" data-new-idx="${i}">
${thumb} ${thumb}
<button type="button" class="diary-media-thumb-del" data-new-idx="${i}" <button type="button" class="diary-media-thumb-del" data-new-idx="${i}"
aria-label="Entfernen">${UI.icon('x')}</button> 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"></button>
</div>`; </div>`;
}).join(''); }).join('');
grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => { grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
@ -673,22 +686,24 @@ window.Page_diary = (() => {
if (!wrap) return; if (!wrap) return;
const items = isEdit ? _allMedia(entry) : []; const items = isEdit ? _allMedia(entry) : [];
if (items.length === 0) { wrap.innerHTML = ''; return; } if (items.length === 0) { wrap.innerHTML = ''; return; }
const grid = `<div class="diary-media-grid" style="margin-bottom:var(--space-2)"> const GRID_STYLE = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
${items.map(m => ` const grid = `<div style="${GRID_STYLE}">
<div class="diary-media-thumb-wrap" data-media-id="${m.id || ''}"> ${items.map((m, idx) => `
<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)"
data-media-id="${m.id ?? ''}">
${m.media_type === 'video' ${m.media_type === 'video'
? `<video src="${m.url}" class="diary-media-thumb" muted playsinline></video>` ? `<video src="${m.url}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
: `<img src="${m.url}" alt="" class="diary-media-thumb">`} : `<img src="${m.url}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`}
${m.id != null <button type="button" class="diary-media-thumb-del"
? `<button type="button" class="diary-media-thumb-del" data-media-id="${m.id}" data-media-id="${m.id ?? ''}" data-legacy="${m.id == null ? '1' : ''}"
aria-label="Entfernen">${UI.icon('x')}</button> aria-label="Entfernen"
<button type="button" 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"></button>
class="diary-cover-btn diary-cover-btn--form${m.is_cover ? ' diary-cover-btn--active' : ''}" ${m.id != null ? `
data-media-id="${m.id}" <button type="button" class="diary-cover-btn diary-cover-btn--form${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}" data-sort="${idx}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}" aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}">&#11088;</button>` title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
: `<button type="button" class="diary-media-thumb-del" data-legacy="1" style="position:absolute;bottom:4px;left:4px;width:28px;height:28px;border-radius:50%;border:none;background:${m.is_cover ? '#f5c518' : 'rgba(0,0,0,.45)'};color:${m.is_cover ? '#fff' : 'rgba(255,255,255,.7)'};cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:2"><svg style="width:16px;height:16px;display:block;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg></button>` : ''}
aria-label="Entfernen">${UI.icon('x')}</button>`}
</div>`).join('')} </div>`).join('')}
</div>`; </div>`;
wrap.innerHTML = grid; wrap.innerHTML = grid;
@ -730,8 +745,12 @@ window.Page_diary = (() => {
wrap.querySelectorAll('.diary-cover-btn--form').forEach(b => { wrap.querySelectorAll('.diary-cover-btn--form').forEach(b => {
const active = parseInt(b.dataset.mediaId) === mediaId; const active = parseInt(b.dataset.mediaId) === mediaId;
b.classList.toggle('diary-cover-btn--active', active); 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('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen');
b.setAttribute('title', 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.'); UI.toast.success('Cover-Bild gesetzt.');
} catch { } catch {
@ -760,12 +779,6 @@ window.Page_diary = (() => {
tmp.click(); tmp.click();
} }
cameraInput?.addEventListener('change', () => {
if (cameraInput.files.length) {
_addFiles(cameraInput.files);
cameraInput.value = '';
}
});
mediaInput?.addEventListener('change', () => { mediaInput?.addEventListener('change', () => {
if (mediaInput.files.length) { if (mediaInput.files.length) {
_addFiles(mediaInput.files); _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); document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Milestone-Toggle // Milestone-Toggle
@ -1168,6 +1177,16 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // 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 }; return { init, refresh, openNew, onDogChange };
})(); })();

View file

@ -27,6 +27,11 @@ window.Page_forum = (() => {
{ key: 'region', label: 'Region' }, { key: 'region', label: 'Region' },
{ key: 'gesundheit', label: 'Gesundheit' }, { key: 'gesundheit', label: 'Gesundheit' },
{ key: 'erziehung', label: 'Erziehung' }, { 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' }, { key: 'tauschboerse', label: 'Tauschbörse' },
]; ];
@ -463,9 +468,7 @@ window.Page_forum = (() => {
// Foto-Vollbild // Foto-Vollbild
document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => { document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => {
img.addEventListener('click', () => { img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src));
window.open(img.dataset.src || img.src, '_blank');
});
}); });
// Reply file preview // Reply file preview
@ -515,6 +518,7 @@ window.Page_forum = (() => {
if (placeholder) listEl.innerHTML = ''; if (placeholder) listEl.innerHTML = '';
listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod)); listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod));
_bindPostActions(listEl, thread.id, uid, isMod); _bindPostActions(listEl, thread.id, uid, isMod);
listEl.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
document.getElementById('forum-reply-text').value = ''; document.getElementById('forum-reply-text').value = '';
const previews = document.getElementById('forum-reply-previews'); const previews = document.getElementById('forum-reply-previews');
@ -620,7 +624,7 @@ window.Page_forum = (() => {
// Foto-Fullscreen // Foto-Fullscreen
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => { container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
img.dataset.bound = '1'; 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 = (() => {
<textarea class="form-control" name="text" rows="5" <textarea class="form-control" name="text" rows="5"
placeholder="Beschreibe dein Thema ausführlich…" required></textarea> placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div> </div>
<div class="form-group">
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<div id="forum-location-picker"></div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Fotos (max. 5)</label> <label class="form-label">Fotos (max. 5)</label>
<div class="forum-upload-area"> <div class="forum-upload-area">
@ -776,6 +784,11 @@ window.Page_forum = (() => {
UI.modal.open({ title: '+ Neues Thema', body, footer }); 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-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules); document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
@ -803,10 +816,15 @@ window.Page_forum = (() => {
} }
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
const created = await API.forum.create({ const created = await API.forum.create({
kategorie: fd.kategorie, kategorie: fd.kategorie,
titel: (fd.titel || '').trim(), titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(), text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
}); });
// Fotos hochladen // Fotos hochladen
@ -824,7 +842,8 @@ window.Page_forum = (() => {
}); });
UI.modal.close(); UI.modal.close();
_renderList(); _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 { try {
const members = await API.forum.membersMap(); const members = await API.forum.membersMap();
members.forEach(m => { members.forEach(m => {
L.marker([m.lat, m.lon]) const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50%;
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`) .bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.addTo(_map); .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 = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}
return { init, refresh, onDogChange, openNew };
})(); })();

View file

@ -196,36 +196,56 @@ window.Page_friends = (() => {
} }
} }
let _activityFilter = 'alle';
let _activityAll = [];
function _renderActivity(items) { function _renderActivity(items) {
_activityAll = items;
const el = _container.querySelector('#fr-activity'); const el = _container.querySelector('#fr-activity');
if (!el) return; if (!el) return;
if (!items.length) { const FILTERS = [
el.innerHTML = ` { key: 'alle', label: 'Alle' },
<div style="margin-top:var(--space-6)"> { key: 'diary', label: 'Tagebuch' },
<div class="by-section-label">Aktivitäten</div> { key: 'walk', label: 'Gassi-Treffen' },
<div style="text-align:center;padding:var(--space-8) var(--space-4)"> { key: 'health', label: 'Gesundheit' },
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border); { key: 'new_dog', label: 'Neuer Hund' },
margin-bottom:var(--space-3)" aria-hidden="true"> ];
<use href="/icons/phosphor.svg#paw-print"></use>
</svg> const chips = FILTERS.map(f => `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0"> <button class="rk-chip${_activityFilter === f.key ? ' active' : ''}"
Noch keine Aktivitäten. Füge Freunde hinzu! data-af="${f.key}">${f.label}</button>
</p> `).join('');
</div>
</div> const filtered = _activityFilter === 'alle'
`; ? items
return; : items.filter(i => i.type === _activityFilter);
}
el.innerHTML = ` el.innerHTML = `
<div style="margin-top:var(--space-6)"> <div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div> <div class="by-section-label">Aktivitäten</div>
<div class="fr-activity-timeline"> <div class="rk-filter-group" style="margin-bottom:var(--space-3);flex-wrap:wrap">
${items.map(item => _activityItem(item)).join('')} ${chips}
</div> </div>
${!filtered.length
? `<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Keine Einträge in dieser Kategorie.
</p>
</div>`
: `<div class="fr-activity-timeline">
${filtered.map(item => _activityItem(item)).join('')}
</div>`
}
</div> </div>
`; `;
el.querySelectorAll('[data-af]').forEach(btn => {
btn.addEventListener('click', () => {
_activityFilter = btn.dataset.af;
_renderActivity(_activityAll);
});
});
} }
function _activityItem(item) { function _activityItem(item) {

View file

@ -678,11 +678,21 @@ window.Page_map = (() => {
return pois.length; return pois.length;
} catch { } catch {
_done++; _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; return _layers[layerKey].filter(m => !m._ownPlace).length;
} }
}); });
await Promise.all(freshTasks); await Promise.all(freshTasks);
_overpassActive = false; _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 };
})(); })();

View file

@ -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 */ /** Rendert eine einzelne Notification als HTML-String */
function _renderItem(n) { function _renderItem(n) {
const unread = !n.read_at; const unread = !n.read_at;
const iconName = unread ? _iconForType(n.type) : 'bell'; 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 ` return `
<div class="${cls}" data-id="${n.id}" data-page="${UI.escape((n.data && JSON.parse(n.data || '{}').page) || '')}"> <div class="${cls}" data-id="${n.id}" data-page="${UI.escape(nav.page)}" data-nav-label="${UI.escape(nav.label)}">
<span class="notif-icon">${UI.icon(iconName)}</span> <span class="notif-icon">${UI.icon(iconName)}</span>
<div class="notif-content"> <div class="notif-content">
<div class="notif-title">${UI.escape(n.title)}</div> <div class="notif-title">${UI.escape(n.title)}</div>
@ -120,14 +143,22 @@ window.Page_notifications = (() => {
const id = parseInt(el.dataset.id, 10); const id = parseInt(el.dataset.id, 10);
const page = el.dataset.page; 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.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) { 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);
} }
}); });
}); });
@ -137,9 +168,18 @@ window.Page_notifications = (() => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); 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 { try {
await API.notifications.delete(id); await API.notifications.delete(id);
btn.closest('.notif-item')?.remove(); item?.remove();
if (!list.querySelector('.notif-item')) { if (!list.querySelector('.notif-item')) {
list.innerHTML = ` list.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@ -147,7 +187,14 @@ window.Page_notifications = (() => {
<p>Keine Benachrichtigungen</p> <p>Keine Benachrichtigungen</p>
</div>`; </div>`;
} }
} 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 { .notif-del-btn {
flex-shrink: 0; flex-shrink: 0;
color: var(--c-text-muted); color: var(--c-text-muted);
opacity: 0; opacity: 0.45;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
min-width: 44px; min-width: 44px;
min-height: 44px; min-height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; opacity: 1;
color: var(--c-danger, #e53e3e);
} }
@media (hover: none) { .notif-del-btn:active {
.notif-del-btn { opacity: 1; } background: var(--c-danger-subtle, rgba(229,62,62,.12));
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);

View file

@ -17,6 +17,8 @@ window.Page_routes = (() => {
let _sortBy = 'newest'; let _sortBy = 'newest';
let _onlyMine = false; let _onlyMine = false;
let _isRecording = false;
// 'mine' | 'discover' // 'mine' | 'discover'
let _browseMode = 'mine'; let _browseMode = 'mine';
@ -57,7 +59,7 @@ window.Page_routes = (() => {
} }
} }
function refresh() { _loadData(); } function refresh() { _syncRecBtn(); _loadData(); }
function onDogChange() {} function onDogChange() {}
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -132,8 +134,16 @@ window.Page_routes = (() => {
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list')); document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map')); document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-rec-btn').addEventListener('click', () => { document.getElementById('rk-rec-btn').addEventListener('click', () => {
if (_isRecording) {
_isRecording = false;
_syncRecBtn();
window.Page_map?.stopRecording?.();
} else {
_isRecording = true;
_syncRecBtn();
App.navigate('map'); App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600); setTimeout(() => window.Page_map?.startRecording?.(), 600);
}
}); });
document.getElementById('rk-import-input').addEventListener('change', e => { document.getElementById('rk-import-input').addEventListener('change', e => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -159,6 +169,22 @@ window.Page_routes = (() => {
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover')); 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) { function _setBrowseMode(mode) {
_browseMode = mode; _browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine'); document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');

View file

@ -262,6 +262,7 @@ window.Page_sitting = (() => {
<div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> max. Hund${s.max_hunde !== 1 ? 'e' : ''}</div> <div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> max. Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
<div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div> <div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div>
</div> </div>
<div id="sit-rating-${s.id}"></div>
`; `;
const footer = _state.user && _mySitter?.user_id !== s.user_id ? ` 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.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', () => { document.getElementById('sit-anfrage-btn')?.addEventListener('click', () => {
UI.modal.close(); UI.modal.close();
setTimeout(() => _openAnfrageForm(s), 50); setTimeout(() => _openAnfrageForm(s), 50);
@ -353,7 +361,7 @@ window.Page_sitting = (() => {
<form id="${id}"> <form id="${id}">
<div class="form-group"> <div class="form-group">
<label class="form-label">Über mich / Beschreibung</label> <label class="form-label">Über mich / Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${s?.beschreibung || ''}</textarea> <textarea class="form-control" name="beschreibung" rows="3">${UI.escape(s?.beschreibung || '')}</textarea>
</div> </div>
<div class="form-row-2"> <div class="form-row-2">
<div class="form-group"> <div class="form-group">
@ -374,17 +382,10 @@ window.Page_sitting = (() => {
</label> </label>
`).join('')} `).join('')}
</div> </div>
<div class="form-row-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Breitengrad</label> <label class="form-label">Mein Standort <span style="color:var(--c-text-secondary)">(für Umkreis-Suche)</span></label>
<input class="form-control" type="number" step="any" name="lat" id="sit-lat" value="${s?.lat || ''}"> <div id="sit-loc-picker"></div>
</div> </div>
<div class="form-group">
<label class="form-label">Längengrad</label>
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">${UI.icon('map-pin')} Meine Position</button>
<div class="form-group" style="margin-top:var(--space-3)"> <div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Umkreis (km)</label> <label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}"> <input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">
@ -408,13 +409,17 @@ window.Page_sitting = (() => {
`; `;
UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer }); UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });
document.getElementById('sit-gps-btn')?.addEventListener('click', async () => { // Location Picker initialisieren
try { let _picker = null;
const pos = await API.getLocation(); setTimeout(() => {
document.getElementById('sit-lat').value = pos.lat.toFixed(6); _picker = UI.locationPicker({
document.getElementById('sit-lon').value = pos.lon.toFixed(6); containerId: 'sit-loc-picker',
} catch { UI.toast('GPS nicht verfügbar.', 'error'); } 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 form = document.getElementById(id);
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]'); const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
@ -423,13 +428,14 @@ window.Page_sitting = (() => {
e.preventDefault(); e.preventDefault();
const fd = new FormData(form); const fd = new FormData(form);
const svcs = [...form.querySelectorAll('[name="services"]:checked')].map(cb => cb.value); 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 = { const data = {
beschreibung: fd.get('beschreibung') || null, beschreibung: fd.get('beschreibung') || null,
preis_pro_tag: parseFloat(fd.get('preis_pro_tag')) || 0, preis_pro_tag: parseFloat(fd.get('preis_pro_tag')) || 0,
max_hunde: parseInt(fd.get('max_hunde')) || 1, max_hunde: parseInt(fd.get('max_hunde')) || 1,
services: svcs, services: svcs,
lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null, lat: loc.lat,
lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null, lon: loc.lon,
radius_km: parseInt(fd.get('radius_km')) || 20, radius_km: parseInt(fd.get('radius_km')) || 20,
}; };
if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0; if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0;

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v213'; const CACHE_VERSION = 'by-v225';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -2,7 +2,7 @@ services:
banyaro: banyaro:
build: . build: .
container_name: banyaro container_name: banyaro
restart: unless-stopped restart: on-failure:5
ports: ports:
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
volumes: volumes:
@ -16,7 +16,7 @@ services:
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
- VAPID_CONTACT=mailto:admin@banyaro.app - VAPID_CONTACT=mailto:admin@banyaro.app
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"] test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3