Sprint 18: Notification Center, Routen entdecken, Onboarding, Admin-Erweiterungen

- Notifications: History-Tabelle, /api/notifications Endpoints, push.py schreibt in DB
- Notifications: Page (notifications.js) mit Badge, Typen-Icons, gelesen-Markierung
- Routen: Entdecken-Modus mit Ersteller-Anzeige, Nearby-Filter, Mine/Discover Toggle
- Onboarding: Willkommens-Modal nach Registrierung, Push-Angebot nach Login
- Admin: Scheduler-Tab (Jobs anzeigen + manuell triggern), System-Health (DB/Disk/Uptime)
- Admin: Audit-Log (wer hat was wann gemacht), erweiterte Stats (Push-Abos, aktive User, Routen)
- SW: by-v152, APP_VER 125
This commit is contained in:
rene 2026-04-17 23:21:48 +02:00
parent 5927d384bf
commit 92620c2c52
14 changed files with 1035 additions and 46 deletions

View file

@ -642,6 +642,21 @@ def _migrate(conn_factory):
""")
logger.info("Migration: diary_dogs Backfill abgeschlossen.")
# Benachrichtigungs-Center
conn.executescript("""
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
data TEXT,
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
""")
# Hund-Teilen: Einladungssystem
conn.executescript("""
CREATE TABLE IF NOT EXISTS dog_shares (

View file

@ -75,7 +75,8 @@ from routes.webcal import router as webcal_router
from routes.profile import router as profile_router
from routes.import_data import router as import_router
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
from routes.widget import router as widget_router
from routes.widget import router as widget_router
from routes.notifications import router as notifications_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -105,6 +106,7 @@ app.include_router(import_router, prefix="/api/import", tags=["Import"
app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"])
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
# ------------------------------------------------------------------

View file

@ -1,12 +1,47 @@
"""BAN YARO — Admin / Moderator Backend"""
import os
import sys
import time
import platform
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from database import db, DB_PATH
from auth import get_current_user
router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin")
_start_time = time.time()
# Audit-Tabelle anlegen (einmalig beim Import)
with db() as _conn:
_conn.executescript("""
CREATE TABLE IF NOT EXISTS admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL,
admin_name TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _audit(conn, admin, action: str, target: str = None, detail: str = None):
"""Schreibt einen Audit-Eintrag in die admin_audit-Tabelle."""
conn.execute(
"INSERT INTO admin_audit (admin_id, admin_name, action, target, detail) VALUES (?,?,?,?,?)",
(admin["id"], admin.get("name"), action, target, detail),
)
# ------------------------------------------------------------------
# Dependency: Moderator oder Admin
@ -60,17 +95,42 @@ async def stats(user=Depends(require_mod)):
"SELECT COUNT(*) FROM users WHERE is_banned=1"
).fetchone()[0]
dogs_total = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
poison_total = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0 AND expires_at > datetime('now')").fetchone()[0]
poison_total = conn.execute(
"SELECT COUNT(*) FROM poison WHERE geloest=0 AND expires_at > datetime('now')"
).fetchone()[0]
# Erweiterte Metriken
push_subscriptions = conn.execute(
"SELECT COUNT(*) FROM push_subscriptions"
).fetchone()[0]
active_users_7d = conn.execute(
"SELECT COUNT(*) FROM users WHERE last_seen > datetime('now', '-7 days')"
).fetchone()[0]
# Media aus diary (media_url) + health (datei_url)
media_diary = conn.execute(
"SELECT COUNT(*) FROM diary WHERE media_url IS NOT NULL AND media_url != ''"
).fetchone()[0]
media_health = conn.execute(
"SELECT COUNT(*) FROM health WHERE datei_url IS NOT NULL AND datei_url != ''"
).fetchone()[0]
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]
return {
"users_total": users_total,
"users_today": users_today,
"threads": threads,
"posts": posts,
"open_reports": open_reports,
"banned": banned,
"dogs_total": dogs_total,
"poison_active":poison_total,
"users_total": users_total,
"users_today": users_today,
"threads": threads,
"posts": posts,
"open_reports": open_reports,
"banned": banned,
"dogs_total": dogs_total,
"poison_active": poison_total,
"push_subscriptions": push_subscriptions,
"active_users_7d": active_users_7d,
"media_count": media_count,
"routes_total": routes_total,
"events_total": events_total,
}
@ -126,7 +186,7 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
raise HTTPException(400, "Ungültige Rolle.")
with db() as conn:
target = conn.execute("SELECT id, rolle FROM users WHERE id=?", (uid,)).fetchone()
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
if not target:
raise HTTPException(404, "User nicht gefunden.")
# Mods dürfen keine Admins sperren
@ -148,6 +208,14 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
(uid,)
).fetchone()
# Audit
detail_parts = []
if "is_banned" in updates:
detail_parts.append("gesperrt" if updates["is_banned"] else "entsperrt")
if "rolle" in updates:
detail_parts.append(f"Rolle→{updates['rolle']}")
_audit(conn, user, "user_patch", f"user:{uid} ({target['name']})", ", ".join(detail_parts) or None)
return dict(row)
@ -157,12 +225,13 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
@router.delete("/users/{uid}", status_code=204)
async def delete_user(uid: int, user=Depends(require_admin)):
with db() as conn:
target = conn.execute("SELECT id, rolle FROM users WHERE id=?", (uid,)).fetchone()
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
if not target:
raise HTTPException(404, "User nicht gefunden.")
if target["id"] == user["id"]:
raise HTTPException(400, "Du kannst deinen eigenen Account nicht löschen.")
conn.execute("DELETE FROM users WHERE id=?", (uid,))
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
# ------------------------------------------------------------------
@ -210,13 +279,15 @@ async def admin_threads(
@router.patch("/forum/threads/{tid}")
async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(require_mod)):
with db() as conn:
if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone():
thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
updates = data.model_dump(exclude_none=True)
if not updates:
raise HTTPException(400, "Keine Änderungen.")
cols = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE forum_threads SET {cols} WHERE id=?", [*updates.values(), tid])
_audit(conn, user, "thread_patch", f"thread:{tid}", str(updates))
return {"ok": True}
@ -226,9 +297,11 @@ async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(requ
@router.delete("/forum/threads/{tid}", status_code=204)
async def admin_delete_thread(tid: int, user=Depends(require_mod)):
with db() as conn:
if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone():
thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
conn.execute("UPDATE forum_threads SET is_deleted=1 WHERE id=?", (tid,))
_audit(conn, user, "thread_delete", f"thread:{tid} ({thread['titel'][:60]})")
# ------------------------------------------------------------------
@ -245,6 +318,7 @@ async def admin_delete_post(pid: int, user=Depends(require_mod)):
"UPDATE forum_threads SET antworten=MAX(0,antworten-1) WHERE id=?",
(post["thread_id"],)
)
_audit(conn, user, "post_delete", f"post:{pid} thread:{post['thread_id']}")
# ------------------------------------------------------------------
@ -277,8 +351,106 @@ async def admin_resolve_report(rid: int, user=Depends(require_mod)):
r = conn.execute("SELECT resolved FROM forum_reports WHERE id=?", (rid,)).fetchone()
if not r:
raise HTTPException(404, "Meldung nicht gefunden.")
new_state = 0 if r["resolved"] else 1
conn.execute(
"UPDATE forum_reports SET resolved=? WHERE id=?",
(0 if r["resolved"] else 1, rid)
(new_state, rid)
)
_audit(conn, user, "report_resolve" if new_state else "report_reopen", f"report:{rid}")
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/admin/scheduler/jobs
# ------------------------------------------------------------------
@router.get("/scheduler/jobs")
async def scheduler_jobs(user=Depends(require_admin)):
from scheduler import _scheduler
jobs = []
for job in _scheduler.get_jobs():
next_run = job.next_run_time
jobs.append({
"id": job.id,
"name": job.name,
"next_run_time": next_run.isoformat() if next_run else None,
"trigger": str(job.trigger),
})
return jobs
# ------------------------------------------------------------------
# POST /api/admin/scheduler/trigger/{job_id}
# ------------------------------------------------------------------
@router.post("/scheduler/trigger/{job_id}")
async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
from scheduler import _scheduler, _TZ as SCHED_TZ
job = _scheduler.get_job(job_id)
if not job:
raise HTTPException(404, f"Job '{job_id}' nicht gefunden.")
job.modify(next_run_time=datetime.now(tz=SCHED_TZ))
with db() as conn:
_audit(conn, user, "scheduler_trigger", f"job:{job_id}")
return {"ok": True, "job_id": job_id}
# ------------------------------------------------------------------
# GET /api/admin/system
# ------------------------------------------------------------------
@router.get("/system")
async def system_info(user=Depends(require_admin)):
# DB-Größe
try:
db_size_mb = os.path.getsize(DB_PATH) / 1024 ** 2
except OSError:
db_size_mb = 0.0
# Media-Größe (rekursiv)
media_dir = os.getenv("MEDIA_DIR", "/data/media")
media_size_bytes = 0
try:
for dirpath, _dirs, files in os.walk(media_dir):
for fname in files:
try:
media_size_bytes += os.path.getsize(os.path.join(dirpath, fname))
except OSError:
pass
except OSError:
pass
media_size_mb = media_size_bytes / 1024 ** 2
# Disk-Info
disk_total_gb = 0.0
disk_free_gb = 0.0
try:
st = os.statvfs(DB_PATH)
disk_total_gb = st.f_blocks * st.f_frsize / 1024 ** 3
disk_free_gb = st.f_bavail * st.f_frsize / 1024 ** 3
except (OSError, AttributeError):
pass
return {
"db_size_mb": round(db_size_mb, 2),
"media_size_mb": round(media_size_mb, 2),
"uptime_seconds": int(time.time() - _start_time),
"python_version": sys.version.split()[0],
"disk_total_gb": round(disk_total_gb, 2),
"disk_free_gb": round(disk_free_gb, 2),
}
# ------------------------------------------------------------------
# GET /api/admin/audit
# ------------------------------------------------------------------
@router.get("/audit")
async def audit_log(limit: int = 50, user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"""
SELECT id, admin_id, admin_name, action, target, detail, created_at
FROM admin_audit
ORDER BY id DESC
LIMIT ?
""",
(min(limit, 200),),
).fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,90 @@
"""BAN YARO — Notification Center Routes"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# GET /api/notifications — letzte 50 Benachrichtigungen des Users
# ------------------------------------------------------------------
@router.get("")
async def list_notifications(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT id, type, title, body, data, read_at, created_at
FROM notifications
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50""",
(user["id"],),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/notifications/unread-count — Anzahl ungelesener Einträge
# ------------------------------------------------------------------
@router.get("/unread-count")
async def unread_count(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL",
(user["id"],),
).fetchone()
return {"count": row[0]}
# ------------------------------------------------------------------
# PATCH /api/notifications/read-all — alle als gelesen markieren
# ------------------------------------------------------------------
@router.patch("/read-all")
async def read_all(user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""UPDATE notifications
SET read_at = datetime('now')
WHERE user_id = ? AND read_at IS NULL""",
(user["id"],),
)
return {"ok": True}
# ------------------------------------------------------------------
# PATCH /api/notifications/{id}/read — einzelne als gelesen markieren
# ------------------------------------------------------------------
@router.patch("/{notif_id}/read")
async def read_one(notif_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM notifications WHERE id=? AND user_id=?",
(notif_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Benachrichtigung nicht gefunden.")
conn.execute(
"UPDATE notifications SET read_at = datetime('now') WHERE id=?",
(notif_id,),
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/notifications/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{notif_id}")
async def delete_one(notif_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM notifications WHERE id=? AND user_id=?",
(notif_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Benachrichtigung nicht gefunden.")
conn.execute("DELETE FROM notifications WHERE id=?", (notif_id,))
return {"ok": True}

View file

@ -104,7 +104,7 @@ def send_push(subscription_row, payload: dict) -> bool:
def send_push_to_user(user_id: int, payload: dict):
"""Schickt Push an alle Subscriptions eines Users."""
"""Schickt Push an alle Subscriptions eines Users und speichert Notification in DB."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM push_subscriptions WHERE user_id=?", (user_id,)
@ -113,6 +113,20 @@ def send_push_to_user(user_id: int, payload: dict):
for row in rows:
if send_push(row, payload):
sent += 1
# Notification in DB persistieren (unabhängig vom Push-Versand)
notif_type = payload.get("type", "info")
notif_title = payload.get("title", "Benachrichtigung")
notif_body = payload.get("body") or payload.get("message")
notif_data = json.dumps({k: v for k, v in payload.items()
if k not in ("type", "title", "body", "message")}) or None
with db() as conn:
conn.execute(
"""INSERT INTO notifications (user_id, type, title, body, data)
VALUES (?, ?, ?, ?, ?)""",
(user_id, notif_type, notif_title, notif_body, notif_data),
)
return sent

View file

@ -1827,6 +1827,42 @@ textarea.form-control {
gap: var(--space-2);
}
.rk-card-author { font-size: var(--text-xs); color: var(--c-text-muted); }
.rk-card-creator {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--c-primary);
margin-bottom: 2px;
}
/* Mode-Toggle: Meine Routen / Entdecken */
.rk-mode-toggle {
display: flex;
gap: 0;
margin-bottom: var(--space-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
overflow: hidden;
align-self: flex-start;
}
.rk-mode-btn {
flex: 1;
padding: 6px 16px;
font-size: var(--text-sm);
font-weight: 500;
background: var(--c-bg);
color: var(--c-text-secondary);
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.rk-mode-btn.active {
background: var(--c-primary);
color: #fff;
}
.rk-mode-btn:first-child { border-right: 1px solid var(--c-border); }
.rk-dl-btn {
font-size: var(--text-xs);
padding: 4px 8px;

View file

@ -22,8 +22,8 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/layout.css?v=87">
<link rel="stylesheet" href="/css/components.css?v=87">
<link rel="stylesheet" href="/css/layout.css?v=93">
<link rel="stylesheet" href="/css/components.css?v=93">
</head>
<body>
@ -68,6 +68,10 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="notifications">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg> Benachrichtigungen
<span class="sidebar-item-badge" id="notif-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Community</span>
<div class="sidebar-item" data-page="poison">
@ -251,6 +255,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-notifications">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
@ -289,9 +297,9 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=92"></script>
<script src="/js/ui.js?v=92"></script>
<script src="/js/app.js?v=92"></script>
<script src="/js/api.js?v=93"></script>
<script src="/js/ui.js?v=93"></script>
<script src="/js/app.js?v=93"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -433,6 +433,17 @@ const API = (() => {
snapshot: () => get('/widget/snapshot'),
};
// ----------------------------------------------------------
// NOTIFICATIONS
// ----------------------------------------------------------
const notifications = {
list() { return get('/notifications'); },
unreadCount() { return get('/notifications/unread-count'); },
readAll() { return patch('/notifications/read-all', {}); },
read(id) { return patch(`/notifications/${id}/read`, {}); },
delete(id) { return del(`/notifications/${id}`); },
};
const importData = {
notestation(dogId, file) {
const fd = new FormData();
@ -465,7 +476,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget,
friends, chat, webcal, importData, sharing, widget, notifications,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '123'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '124'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -56,7 +56,8 @@ const App = (() => {
admin: { title: 'Admin', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Benachrichtigungen', module: null, requiresAuth: true },
};
// ----------------------------------------------------------
@ -409,6 +410,26 @@ const App = (() => {
adminItem.style.display = isMod ? '' : 'none';
}
await _loadDogs();
// Eingeloggter User ohne Hund (z.B. nach Reload) → direkt zur Hund-Anlage
if (state.dogs.length === 0) {
navigate('dog-profile');
}
_updateNotifBadge();
// Badge alle 60s aktualisieren
setInterval(_updateNotifBadge, 60_000);
}
async function _updateNotifBadge() {
if (!state.user) return;
try {
const { count } = await API.notifications.unreadCount();
const badge = document.getElementById('notif-badge');
if (!badge) return;
badge.textContent = count;
badge.style.display = count > 0 ? '' : 'none';
} catch { /* ignorieren */ }
}
function _onLoggedOut() {
@ -446,6 +467,50 @@ const App = (() => {
} catch { /* kein Hund vorhanden */ }
}
// ----------------------------------------------------------
// ONBOARDING — Willkommens-Modal für neue User
// ----------------------------------------------------------
function _showOnboardingModal() {
UI.modal.open({
title: `${UI.icon('paw-print')} Willkommen bei Ban Yaro!`,
body: `
<div style="display:flex;flex-direction:column;align-items:center;
gap:var(--space-4);text-align:center;padding:var(--space-2) 0">
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use>
</svg>
</div>
<div style="max-width:300px">
<p style="margin:0 0 var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Schön, dass du dabei bist! Ban Yaro hilft dir, alles rund um
deinen Hund im Blick zu behalten Spaziergänge, Gesundheit,
Termine und vieles mehr.
</p>
<p style="margin:0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Fang jetzt an und leg ein Profil für deinen Hund an.
</p>
</div>
</div>
`,
footer: `
<button type="button" class="btn btn-primary" id="onboarding-start-btn">
${UI.icon('dog')} Meinen ersten Hund anlegen
</button>
<button type="button" class="btn btn-ghost" data-modal-close>Später</button>
`,
});
document.getElementById('onboarding-start-btn')?.addEventListener('click', () => {
UI.modal.close();
navigate('dog-profile');
});
}
function _notifyDogChange() {
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
}
@ -646,7 +711,9 @@ const App = (() => {
// (andere Module können App.state, App.navigate etc. nutzen)
// ----------------------------------------------------------
return { init, navigate, state, setActiveDog, renderDogSwitcher: _renderDogSwitcher,
getInstallPrompt: () => _installPrompt, requireAuth };
getInstallPrompt: () => _installPrompt, requireAuth,
showOnboarding: _showOnboardingModal,
updateNotifBadge: _updateNotifBadge };
})();

View file

@ -10,9 +10,12 @@ window.Page_admin = (() => {
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
{ id: 'system', label: 'System', icon: 'cpu' },
{ id: 'jobs', label: 'Jobs', icon: 'timer' },
{ id: 'audit', label: 'Audit-Log', icon: 'list-bullets' },
];
// ------------------------------------------------------------------
@ -76,6 +79,9 @@ window.Page_admin = (() => {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -90,13 +96,18 @@ window.Page_admin = (() => {
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-3);
margin-bottom:var(--space-5)">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
${_statCard('warning', 'Offene Meldungen',s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('warning-octagon','Giftk. aktiv', s.poison_active,'var(--c-danger)')}
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, '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('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
</div>
<div class="card" style="padding:var(--space-4)">
@ -532,6 +543,204 @@ window.Page_admin = (() => {
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// TAB: SYSTEM
// ------------------------------------------------------------------
async function _renderSystem(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-sys-cards">Lade</div>
`;
el.querySelector('#adm-sys-refresh').addEventListener('click', () => _loadSystemCards(el.querySelector('#adm-sys-cards')));
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
}
async function _loadSystemCards(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const s = await API.get('/admin/system');
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
const uptime = _formatUptime(s.uptime_seconds);
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:var(--space-3)">
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
</div>
</div>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
Python ${_esc(s.python_version)}
</div>
</div>
`;
}
function _formatUptime(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}min`;
return `${m}min`;
}
// ------------------------------------------------------------------
// TAB: JOBS
// ------------------------------------------------------------------
async function _renderJobs(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-jobs-list">Lade</div>
`;
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
await _loadJobs(el.querySelector('#adm-jobs-list'));
}
async function _loadJobs(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const jobs = await API.get('/admin/scheduler/jobs');
if (!jobs.length) {
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
return;
}
el.innerHTML = `
<div class="card" style="overflow:hidden">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Job</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Nächster Lauf</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Trigger</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)"></th>
</tr>
</thead>
<tbody>
${jobs.map((j, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(j.name)}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">${_esc(j.id)}</div>
</td>
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-secondary)">
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span style="color:var(--c-text-muted)">—</span>'}
</td>
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-xs);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(j.trigger)}
</td>
<td style="padding:var(--space-3) var(--space-4);text-align:right">
<button class="btn btn-sm btn-ghost adm-job-trigger" data-id="${_esc(j.id)}" data-name="${_esc(j.name)}"
title="Jetzt ausführen" style="color:var(--c-primary)">
${UI.icon('play')}
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
} finally {
btn.disabled = false;
}
});
});
}
function _formatDateTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
} catch { return iso; }
}
// ------------------------------------------------------------------
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-audit-list">Lade</div>
`;
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
await _loadAudit(el.querySelector('#adm-audit-list'));
}
async function _loadAudit(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const rows = await API.get('/admin/audit?limit=50');
if (!rows.length) {
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
return;
}
el.innerHTML = `
<div class="card" style="overflow:hidden">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Wann</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Admin</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Aktion</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Ziel</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
${_formatDateTime(r.created_at)}
</td>
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text)">
${_esc(r.admin_name || '—')}
</td>
<td style="padding:var(--space-2) var(--space-4)">
<span style="font-size:var(--text-xs);padding:2px 7px;border-radius:3px;
background:var(--c-surface-2);color:var(--c-text-secondary);font-family:monospace">
${_esc(r.action)}
</span>
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(r.detail)}</div>` : ''}
</td>
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-secondary);font-size:var(--text-xs)">
${_esc(r.target || '—')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------

View file

@ -0,0 +1,229 @@
/* BAN YARO — Notification Center */
window.Page_notifications = (() => {
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
/** Relativer Zeitstempel: "vor 2h", "vor 3 Tagen", etc. */
function _relTime(isoStr) {
if (!isoStr) return '';
const diff = Date.now() - new Date(isoStr + (isoStr.includes('Z') ? '' : 'Z')).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h}h`;
const d = Math.floor(h / 24);
if (d < 30) return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
const mo = Math.floor(d / 30);
return `vor ${mo} Monat${mo !== 1 ? 'en' : ''}`;
}
/** Phosphor-Icon-Name je nach Notification-Typ */
function _iconForType(type) {
switch (type) {
case 'chat_message': return 'chat-circle';
case 'friend_request': return 'user-plus';
case 'health_reminder': return 'first-aid';
case 'milestone': return 'star';
case 'poison_alert': return 'warning';
default: return 'bell';
}
}
/** Rendert eine einzelne Notification als HTML-String */
function _renderItem(n) {
const unread = !n.read_at;
const iconName = unread ? _iconForType(n.type) : 'bell';
const cls = ['notif-item', unread ? 'notif-unread' : ''].filter(Boolean).join(' ');
return `
<div class="${cls}" data-id="${n.id}" data-page="${UI.escape((n.data && JSON.parse(n.data || '{}').page) || '')}">
<span class="notif-icon">${UI.icon(iconName)}</span>
<div class="notif-content">
<div class="notif-title">${UI.escape(n.title)}</div>
${n.body ? `<div class="notif-body">${UI.escape(n.body)}</div>` : ''}
<div class="notif-time">${_relTime(n.created_at)}</div>
</div>
<button class="notif-del-btn icon-btn" data-del="${n.id}" title="Löschen"
aria-label="Benachrichtigung löschen">
${UI.icon('x')}
</button>
</div>`;
}
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
async function init(container, appState, params) {
container.innerHTML = `
<div class="page-header">
<h2>${UI.icon('bell')} Benachrichtigungen</h2>
<button class="btn btn-sm btn-ghost" id="notif-read-all">Alle gelesen</button>
</div>
<div id="notif-list" class="notif-list">
<div class="loading-spinner"></div>
</div>`;
_addStyles();
// Daten laden
let items = [];
try {
items = await API.notifications.list();
} catch (e) {
document.getElementById('notif-list').innerHTML =
`<div class="empty-state">${UI.icon('warning')} Fehler beim Laden.</div>`;
return;
}
_render(items);
// "Alle gelesen"-Button
document.getElementById('notif-read-all')?.addEventListener('click', async () => {
try {
await API.notifications.readAll();
// Alle als gelesen markieren (lokal)
items = items.map(n => ({ ...n, read_at: new Date().toISOString() }));
_render(items);
} catch (e) {
UI.toast?.('Fehler beim Markieren.', 'error');
}
});
}
// ----------------------------------------------------------
// Render-Helfer
// ----------------------------------------------------------
function _render(items) {
const list = document.getElementById('notif-list');
if (!list) return;
if (!items || items.length === 0) {
list.innerHTML = `
<div class="empty-state">
${UI.icon('bell-slash')}
<p>Keine Benachrichtigungen</p>
</div>`;
return;
}
list.innerHTML = items.map(_renderItem).join('');
// Klick auf Notification: als gelesen + ggf. navigieren
list.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', async (e) => {
// 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;
// Optisch sofort als gelesen markieren
el.classList.remove('notif-unread');
try { await API.notifications.read(id); } catch (_) {}
if (page && window.App?.navigate) {
window.App.navigate(page);
}
});
});
// Löschen-Buttons
list.querySelectorAll('.notif-del-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.del, 10);
try {
await API.notifications.delete(id);
btn.closest('.notif-item')?.remove();
if (!list.querySelector('.notif-item')) {
list.innerHTML = `
<div class="empty-state">
${UI.icon('bell-slash')}
<p>Keine Benachrichtigungen</p>
</div>`;
}
} catch (_) {}
});
});
}
// ----------------------------------------------------------
// Inline-Styles (einmalig einfügen)
// ----------------------------------------------------------
function _addStyles() {
if (document.getElementById('notif-styles')) return;
const style = document.createElement('style');
style.id = 'notif-styles';
style.textContent = `
.notif-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) 0;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--c-surface);
border: 1px solid var(--c-border-light);
cursor: pointer;
transition: background var(--transition-fast);
}
.notif-item:hover {
background: var(--c-surface-2);
}
.notif-item.notif-unread {
border-left: 3px solid var(--c-primary);
background: var(--c-primary-subtle);
}
.notif-item.notif-unread .notif-title {
font-weight: var(--weight-semibold);
}
.notif-icon {
flex-shrink: 0;
color: var(--c-primary);
margin-top: 2px;
}
.notif-content {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: var(--text-sm);
color: var(--c-text);
line-height: var(--leading-snug);
}
.notif-body {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
.notif-time {
font-size: var(--text-xs);
color: var(--c-text-muted);
margin-top: var(--space-1);
}
.notif-del-btn {
flex-shrink: 0;
color: var(--c-text-muted);
opacity: 0;
transition: opacity var(--transition-fast);
}
.notif-item:hover .notif-del-btn {
opacity: 1;
}
`;
document.head.appendChild(style);
}
return { init };
})();

View file

@ -17,6 +17,9 @@ window.Page_routes = (() => {
let _sortBy = 'newest';
let _onlyMine = false;
// 'mine' | 'discover'
let _browseMode = 'mine';
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
@ -76,6 +79,10 @@ window.Page_routes = (() => {
_container.innerHTML = `
<div class="rk-layout">
<div class="rk-header">
<div class="rk-mode-toggle" id="rk-mode-toggle">
<button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button>
<button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('compass')} Entdecken</button>
</div>
<div class="rk-search-row">
<input class="rk-search" id="rk-search" type="search"
placeholder="🔍 Route suchen…" autocomplete="off">
@ -83,7 +90,7 @@ window.Page_routes = (() => {
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
</div>
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
<label class="btn btn-secondary btn-sm rk-imp-btn" id="rk-imp-wrap" title="GPX / KML / TCX importieren">
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
</label>
@ -112,6 +119,9 @@ window.Page_routes = (() => {
<div class="rk-filter-group" id="rk-mine-group" style="display:none">
<button class="rk-chip" data-filter="mine" data-val="mine">🔒 Nur meine</button>
</div>
<div class="rk-filter-group" id="rk-nearby-group" style="display:none">
<button class="rk-chip" id="rk-nearby-btn" data-filter="nearby" data-val="">${UI.icon('map-pin')} In meiner Nähe</button>
</div>
</div>
</div>
<div class="rk-grid" id="rk-grid">
@ -145,8 +155,50 @@ window.Page_routes = (() => {
if (filter === 'terrain') _terrain = val;
if (filter === 'sort') _sortBy = val;
if (filter === 'mine') _onlyMine = chip.classList.contains('active') && val === 'mine';
if (filter === 'nearby') { _loadDataNearby(); return; } // async, calls _applyFilter itself
_applyFilter();
});
// Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
}
function _setBrowseMode(mode) {
_browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover');
const recBtn = document.getElementById('rk-rec-btn');
const impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-group');
const nearbyGrp = document.getElementById('rk-nearby-group');
if (mode === 'discover') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.style.display = 'none';
if (nearbyGrp && _userPos) nearbyGrp.style.display = '';
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.style.display = '';
if (nearbyGrp) nearbyGrp.style.display = 'none';
}
_onlyMine = false;
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter();
}
async function _loadDataNearby() {
if (!_userPos) {
try { _userPos = await API.getLocation(); } catch { UI.toast.warning('Standort nicht verfügbar.'); return; }
}
try {
_data = await API.routes.listNearby(_userPos.lat, _userPos.lon, 10000);
_applyFilter();
} catch (err) {
UI.toast.error('Fehler beim Laden: ' + err.message);
}
}
// ----------------------------------------------------------
@ -328,10 +380,14 @@ window.Page_routes = (() => {
async function _loadData() {
try {
_data = await API.routes.list();
// "Meine Routen"-Filter nur zeigen wenn eingeloggt
if (_appState.user) {
// "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
if (_appState.user && _browseMode === 'mine') {
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
}
// Standort-abhängiger Filter im Entdecken-Modus
if (_browseMode === 'discover' && _userPos) {
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
}
_applyFilter();
} catch (err) {
document.getElementById('rk-grid').innerHTML =
@ -346,13 +402,21 @@ window.Page_routes = (() => {
function _applyFilter() {
let list = [..._data];
// Browse-Modus-Filter
if (_browseMode === 'mine' && _appState.user) {
list = list.filter(r => r.user_id === _appState.user.id);
} else if (_browseMode === 'discover' && _appState.user) {
list = list.filter(r => r.user_id !== _appState.user.id);
}
if (_search) list = list.filter(r =>
(r.name||'').toLowerCase().includes(_search) ||
(r.beschreibung||'').toLowerCase().includes(_search) ||
(r.user_name||'').toLowerCase().includes(_search));
if (_difficulty) list = list.filter(r => r.schwierigkeit === _difficulty);
if (_terrain) list = list.filter(r => r.untergrund === _terrain);
if (_onlyMine && _appState.user)
if (_onlyMine && _appState.user && _browseMode === 'mine')
list = list.filter(r => r.user_id === _appState.user.id);
if (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0));
@ -387,8 +451,15 @@ window.Page_routes = (() => {
document.querySelector('.rk-chip[data-val="newest"]')?.classList.add('active');
_applyFilter();
});
} else if (_browseMode === 'discover') {
// Entdecken: keine fremden Routen vorhanden
grid.innerHTML = `<div class="rk-empty">
<div class="rk-empty-icon">${UI.icon('compass')}</div>
<h3 class="rk-empty-title">Noch keine öffentlichen Routen</h3>
<p class="rk-empty-text">Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!</p>
</div>`;
} else {
// Noch gar keine Routen
// Noch gar keine eigenen Routen
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding">
<div class="rk-empty-icon">🥾</div>
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
@ -440,6 +511,7 @@ window.Page_routes = (() => {
// Karte HTML
// ----------------------------------------------------------
function _cardHTML(r) {
const isDiscover = _browseMode === 'discover';
const privBadge = !r.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || '';
const terrain = TERRAIN_LABEL[r.untergrund] || '';
@ -453,10 +525,15 @@ window.Page_routes = (() => {
data-track='${JSON.stringify(r.preview_track||[])}'
style="width:100%;height:100%"></div>`;
const authorLine = isDiscover
? `<div class="rk-card-creator">${UI.icon('user')} ${_esc(r.user_name||'Anonym')}</div>`
: '';
return `
<div class="rk-card" data-id="${r.id}">
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
${authorLine}
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
@ -465,7 +542,7 @@ window.Page_routes = (() => {
${paws ? `<span title="Hundetauglichkeit">${paws}</span>` : ''}
</div>
<div class="rk-card-tags">
${privBadge}
${isDiscover ? '' : privBadge}
${diffLabel ? `<span class="rk-badge rk-badge--${r.schwierigkeit}">${diffLabel}</span>` : ''}
${r.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${r.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine</span>` : ''}
@ -473,7 +550,7 @@ window.Page_routes = (() => {
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>
${isDiscover ? '' : `<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>`}
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>

View file

@ -551,6 +551,11 @@ window.Page_settings = (() => {
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
_offerPushNotifications();
}
// Nach Login: Tagebuch oder Profil anlegen
if (_appState.activeDog) {
App.navigate('diary');
@ -585,13 +590,67 @@ window.Page_settings = (() => {
_appState.dogs = [];
_appState.activeDog = null;
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}! 🐕`);
// Direkt zur Profil-Anlage
App.navigate('dog-profile');
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
// Onboarding-Modal direkt zeigen (SPA — kein Reload)
App.showOnboarding();
});
});
}
// ----------------------------------------------------------
// PUSH-BENACHRICHTIGUNGEN ANBIETEN (nach Login)
// ----------------------------------------------------------
function _offerPushNotifications() {
// Kleiner Toast-Banner mit Ja-Button — nicht-invasiv
const toastEl = document.createElement('div');
toastEl.id = 'push-offer-banner';
toastEl.style.cssText = [
'position:fixed',
'bottom:calc(var(--nav-h, 64px) + var(--space-3))',
'left:50%',
'transform:translateX(-50%)',
'background:var(--c-surface)',
'border:1.5px solid var(--c-border)',
'border-radius:var(--radius-lg)',
'box-shadow:var(--shadow-lg)',
'padding:var(--space-3) var(--space-4)',
'display:flex',
'align-items:center',
'gap:var(--space-3)',
'font-size:var(--text-sm)',
'z-index:1100',
'max-width:340px',
'width:calc(100% - var(--space-8))',
].join(';');
toastEl.innerHTML = `
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bell-ringing"></use>
</svg>
<span style="flex:1;line-height:1.4">Push-Benachrichtigungen aktivieren?</span>
<button id="push-offer-yes" class="btn btn-primary" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);flex-shrink:0">Ja</button>
<button id="push-offer-no" class="btn btn-ghost btn-icon" aria-label="Schließen" style="flex-shrink:0">
<svg class="ph-icon" style="width:16px;height:16px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
`;
document.body.appendChild(toastEl);
const remove = () => toastEl.remove();
document.getElementById('push-offer-yes')?.addEventListener('click', async () => {
remove();
try {
await API.subscribeToPush();
UI.toast.success('Push-Benachrichtigungen aktiviert.');
} catch {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('push-offer-no')?.addEventListener('click', remove);
// Automatisch ausblenden nach 12 Sekunden
setTimeout(remove, 12000);
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------

View file

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