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"),
# 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:

View file

@ -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"])
# ------------------------------------------------------------------

View file

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

View file

@ -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]
# ------------------------------------------------------------------

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 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}")

View file

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

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(
_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:

View file

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

View file

@ -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: `
<div class="flex flex-col gap-3">
${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')}
<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
</button>
${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')}
${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')}
</div>
${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
@ -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 });
}

View file

@ -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)')}
</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 class="card" style="padding:var(--space-4)">
@ -543,9 +557,46 @@ window.Page_admin = (() => {
</button>
</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 loadLogs();
}
async function _loadSystemCards(el) {

View file

@ -41,13 +41,18 @@ window.Page_chat = (() => {
_container.innerHTML = `
<div style="background:var(--c-surface)">
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold)">Nachrichten</h2>
<div style="display:flex;align-items:center;justify-content:space-between;
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 id="chat-list-body"></div>
</div>
`;
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
await _loadList();
await _updateChatBadge();
}
@ -396,6 +401,51 @@ window.Page_chat = (() => {
.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 {
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>' },
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>' },
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>' },
};
@ -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 `<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
// ----------------------------------------------------------
@ -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'}">&#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>`)
: '';
@ -417,7 +431,6 @@ window.Page_diary = (() => {
const body = `
${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)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
@ -425,24 +438,31 @@ window.Page_diary = (() => {
</span>
</div>
${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>
${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>` : ''}
${dogsHtml}
${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
? `<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('')}
</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 });
// 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 = (() => {
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckte Inputs -->
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none">
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
<!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*" multiple style="display:none">
<!-- Auswahlbuttons immer sichtbar -->
<div class="diary-media-picker" style="margin-top:var(--space-2)">
<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>
Mediathek
</button>
<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>
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<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">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
Fotos / Videos hinzufügen
</label>
</div>
</form>
`;
@ -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/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<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}
<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>`;
}).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 = `<div class="diary-media-grid" style="margin-bottom:var(--space-2)">
${items.map(m => `
<div class="diary-media-thumb-wrap" data-media-id="${m.id || ''}">
const GRID_STYLE = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
const grid = `<div style="${GRID_STYLE}">
${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'
? `<video src="${m.url}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${m.url}" alt="" class="diary-media-thumb">`}
${m.id != null
? `<button type="button" class="diary-media-thumb-del" data-media-id="${m.id}"
aria-label="Entfernen">${UI.icon('x')}</button>
<button type="button"
class="diary-cover-btn diary-cover-btn--form${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'}">&#11088;</button>`
: `<button type="button" class="diary-media-thumb-del" data-legacy="1"
aria-label="Entfernen">${UI.icon('x')}</button>`}
? `<video src="${m.url}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
: `<img src="${m.url}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`}
<button type="button" class="diary-media-thumb-del"
data-media-id="${m.id ?? ''}" data-legacy="${m.id == null ? '1' : ''}"
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>
${m.id != null ? `
<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'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
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>` : ''}
</div>`).join('')}
</div>`;
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 };
})();

View file

@ -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 = (() => {
<textarea class="form-control" name="text" rows="5"
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</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">
<label class="form-label">Fotos (max. 5)</label>
<div class="forum-upload-area">
@ -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: `<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>`)
.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) {
_activityAll = items;
const el = _container.querySelector('#fr-activity');
if (!el) return;
if (!items.length) {
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Noch keine Aktivitäten. Füge Freunde hinzu!
</p>
</div>
</div>
`;
return;
}
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 => `
<button class="rk-chip${_activityFilter === f.key ? ' active' : ''}"
data-af="${f.key}">${f.label}</button>
`).join('');
const filtered = _activityFilter === 'alle'
? items
: items.filter(i => i.type === _activityFilter);
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div class="fr-activity-timeline">
${items.map(item => _activityItem(item)).join('')}
<div class="rk-filter-group" style="margin-bottom:var(--space-3);flex-wrap:wrap">
${chips}
</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>
`;
el.querySelectorAll('[data-af]').forEach(btn => {
btn.addEventListener('click', () => {
_activityFilter = btn.dataset.af;
_renderActivity(_activityAll);
});
});
}
function _activityItem(item) {

View file

@ -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 };
})();

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 */
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 `
<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>
<div class="notif-content">
<div class="notif-title">${UI.escape(n.title)}</div>
@ -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 = `
<div class="empty-state">
@ -147,7 +187,14 @@ window.Page_notifications = (() => {
<p>Keine Benachrichtigungen</p>
</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 {
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);

View file

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

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.radius_km} km</strong> Umkreis</div>
</div>
<div id="sit-rating-${s.id}"></div>
`;
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 = (() => {
<form id="${id}">
<div class="form-group">
<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 class="form-row-2">
<div class="form-group">
@ -374,17 +382,10 @@ window.Page_sitting = (() => {
</label>
`).join('')}
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Breitengrad</label>
<input class="form-control" type="number" step="any" name="lat" id="sit-lat" value="${s?.lat || ''}">
</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 class="form-group">
<label class="form-label">Mein Standort <span style="color:var(--c-text-secondary)">(für Umkreis-Suche)</span></label>
<div id="sit-loc-picker"></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)">
<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}">
@ -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;

View file

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

View file

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