Compare commits

..

6 commits

12 changed files with 548 additions and 58 deletions

View file

@ -2210,6 +2210,22 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass pass
# Gassi-Treffen Fotos
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS walk_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_walk_photos_walk ON walk_photos(walk_id)")
logger.info("Migration: walk_photos bereit.")
except Exception as e:
logger.warning(f"Migration walk_photos: {e}")
# Versicherungs-Verwaltung # Versicherungs-Verwaltung
try: try:
conn.execute(""" conn.execute("""

View file

@ -354,29 +354,59 @@ _MIME_MAP = {
".webm": "video/webm", ".pdf": "application/pdf", ".webm": "video/webm", ".pdf": "application/pdf",
} }
if STAGING and os.path.isdir(PROD_MEDIA_DIR): from fastapi import Request as _Request
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) from fastapi.responses import FileResponse as _FileResponse
from fastapi.responses import FileResponse as _FileResponse from auth import decode_token as _decode_token
def _media_response(filepath: str): # Pfade die Login erfordern (kein DB-Lookup — UUID in Dateiname schützt ausreichend)
_AUTH_REQUIRED = ("diary/", "health/", "walks/")
def _is_logged_in(request: _Request) -> bool:
token = request.cookies.get("by_token")
if not token:
return False
try:
_decode_token(token)
return True
except Exception:
return False
def _media_response(filepath: str):
ext = os.path.splitext(filepath)[1].lower() ext = os.path.splitext(filepath)[1].lower()
mt = _MIME_MAP.get(ext, "application/octet-stream") mt = _MIME_MAP.get(ext, "application/octet-stream")
return _FileResponse(filepath, media_type=mt) return _FileResponse(filepath, media_type=mt,
headers={"Cache-Control": "private, max-age=3600"})
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
async def serve_media_staging(path: str): def _resolve_media_path(path: str) -> str | None:
staging_file = os.path.join(MEDIA_DIR, path) primary = os.path.join(MEDIA_DIR, path)
if os.path.isfile(staging_file): if os.path.isfile(primary):
return _media_response(staging_file) return primary
prod_file = os.path.join(PROD_MEDIA_DIR, path) if STAGING and os.path.isdir(PROD_MEDIA_DIR):
if os.path.isfile(prod_file): fallback = os.path.join(PROD_MEDIA_DIR, path)
return _media_response(prod_file) if os.path.isfile(fallback):
return fallback
return None
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
async def serve_media(path: str, request: _Request):
from fastapi import HTTPException as _HE from fastapi import HTTPException as _HE
raise _HE(404, "Media not found")
else:
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen prefix = path.split("/")[0] + "/"
# Sensible Pfade: Login erforderlich — UUID-basierte Dateinamen verhindern Raten
if prefix in _AUTH_REQUIRED and not _is_logged_in(request):
raise _HE(401, "Anmeldung erforderlich.")
filepath = _resolve_media_path(path)
if not filepath:
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "883" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -167,3 +167,134 @@ async def delete_account(user=Depends(get_current_user)):
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"} return {"status": "deleted"}
# ----------------------------------------------------------
# GET /profile/export — DSGVO Datenexport (Art. 20)
# ----------------------------------------------------------
@router.get('/export')
async def export_user_data(user=Depends(get_current_user)):
"""Gibt alle personenbezogenen Daten des Users als JSON zurück (Art. 20 DSGVO)."""
import json as _json
from datetime import datetime as _dt
from fastapi.responses import Response as _Response
def _q(conn, sql, params=()):
"""Sicheres Query — gibt leere Liste zurück wenn Tabelle/Spalte fehlt."""
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
except Exception:
return []
def _q1(conn, sql, params=()):
"""Single-Row-Query — gibt None zurück bei Fehler."""
try:
r = conn.execute(sql, params).fetchone()
return dict(r) if r else None
except Exception:
return None
uid = user['id']
with db() as conn:
# Nutzerprofil
u = _q1(conn,
"SELECT id, name, email, bio, wohnort, erfahrung, social_link, "
"email_verified, is_premium, subscription_tier, created_at "
"FROM users WHERE id=?", (uid,)) or {}
# Hunde
dogs_raw = _q(conn, "SELECT * FROM dogs WHERE user_id=?", (uid,))
dogs_out = []
for dog in dogs_raw:
did = dog['id']
# Tagebuch (nur vorhandene Spalten)
diary_rows = _q(conn,
"SELECT id, datum, typ, titel, text, gps_lat, gps_lon, "
"is_milestone, created_at FROM diary WHERE dog_id=?", (did,))
for de in diary_rows:
# diary_media: preview_url existiert nicht → url + media_type
de['media'] = _q(conn,
"SELECT url, media_type FROM diary_media WHERE diary_id=?",
(de['id'],))
# Gesundheit (alle via Migration ergänzten Spalten schützen)
health_rows = _q(conn,
"SELECT id, typ, bezeichnung, datum, naechstes, notiz FROM health "
"WHERE dog_id=?", (did,))
for he in health_rows:
he['media'] = _q(conn,
"SELECT url, media_type FROM health_media WHERE health_id=?",
(he['id'],))
dog['tagebuch'] = diary_rows
dog['gesundheit'] = health_rows
dog['trainingsfortschritt'] = _q(conn,
"SELECT exercise_id, status, updated_at FROM exercise_progress "
"WHERE dog_id=?", (did,))
dog['ausgaben'] = _q(conn,
"SELECT datum, betrag, kategorie, notiz FROM expenses "
"WHERE dog_id=?", (did,))
dog['verhaltensprotokoll'] = _q(conn,
"SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz "
"FROM behavior_log WHERE dog_id=?", (did,))
dog['versicherung'] = _q(conn,
"SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen "
"FROM dog_insurance WHERE dog_id=?", (did,))
dog['ernaehrungsprofil'] = _q1(conn,
"SELECT futter_typ, marke, kcal_tag, portionen, notizen "
"FROM futter_profil WHERE dog_id=?", (did,))
dog['futter_eintraege'] = _q(conn,
"SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz "
"FROM futter_eintraege WHERE dog_id=?", (did,))
dog['futter_reaktionen'] = _q(conn,
"SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz "
"FROM futter_reaktionen WHERE dog_id=?", (did,))
dog['routen'] = _q(conn,
"SELECT r.name, r.distanz_km, date(r.created_at) AS datum "
"FROM routes r JOIN route_dogs rd ON rd.route_id=r.id "
"WHERE rd.dog_id=?", (did,))
dogs_out.append(dog)
forum = _q(conn,
"SELECT ft.title, fp.content, fp.created_at, "
"CASE WHEN fp.parent_id IS NULL THEN 'Thread' ELSE 'Antwort' END AS art "
"FROM forum_posts fp LEFT JOIN forum_threads ft ON ft.id=fp.thread_id "
"WHERE fp.user_id=? ORDER BY fp.created_at DESC", (uid,))
walk_participations = _q(conn,
"SELECT w.titel, w.datum, w.uhrzeit, w.ort_name "
"FROM walk_participants wp JOIN walks w ON w.id=wp.walk_id "
"WHERE wp.user_id=?", (uid,))
walk_photos = _q(conn,
"SELECT wp.url, w.datum AS walk_datum, w.titel AS walk_titel, wp.created_at "
"FROM walk_photos wp JOIN walks w ON w.id=wp.walk_id "
"WHERE wp.user_id=?", (uid,))
push_count = _q1(conn,
"SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id=?",
(uid,))
push_count = (push_count or {}).get('n', 0)
export = {
"export_erstellt": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"hinweis": "Dieser Export enthält alle personenbezogenen Daten deines Ban-Yaro-Kontos gemäß Art. 20 DSGVO.",
"profil": u,
"hunde": dogs_out,
"forum_beitraege": [dict(f) for f in forum],
"gassi_teilnahmen": [dict(w) for w in walk_participations],
"gassi_fotos": [dict(p) for p in walk_photos],
"push_subscriptions": push_count,
}
content = _json.dumps(export, ensure_ascii=False, indent=2, default=str)
today = _dt.utcnow().strftime("%Y-%m-%d")
filename = f"banyaro-export-{today}.json"
return _Response(
content = content,
media_type = "application/json",
headers = {"Content-Disposition": f'attachment; filename="{filename}"'},
)

View file

@ -1,15 +1,17 @@
"""BAN YARO — Gassi-Treffen""" """BAN YARO — Gassi-Treffen"""
import math import math, os, uuid
import httpx import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from routes.push import send_push_to_user from routes.push import send_push_to_user
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
router = APIRouter() router = APIRouter()
@ -371,9 +373,34 @@ async def get_walk(walk_id: int):
GROUP BY wp.user_id GROUP BY wp.user_id
""", (walk_id,)).fetchall() """, (walk_id,)).fetchall()
# Hunde-Details (Foto + Rasse) pro Teilnehmer
dog_rows = conn.execute("""
SELECT wpd.user_id, d.name AS dog_name, d.foto_url, d.rasse
FROM walk_participant_dogs wpd
JOIN dogs d ON d.id = wpd.dog_id
WHERE wpd.walk_id = ?
""", (walk_id,)).fetchall()
# Walk-Fotos
photos = conn.execute(
"SELECT id, user_id, url, created_at FROM walk_photos WHERE walk_id=? ORDER BY created_at",
(walk_id,)
).fetchall()
from collections import defaultdict
dogs_by_user = defaultdict(list)
for r in dog_rows:
dogs_by_user[r['user_id']].append({
'name': r['dog_name'], 'foto_url': r['foto_url'], 'rasse': r['rasse']
})
result = dict(walk) result = dict(walk)
result['teilnehmer'] = [dict(p) for p in participants] result['teilnehmer'] = [
{**dict(p), 'hunde_liste': dogs_by_user.get(p['user_id'], [])}
for p in participants
]
result['teilnehmer_count'] = len(result['teilnehmer']) result['teilnehmer_count'] = len(result['teilnehmer'])
result['photos'] = [dict(p) for p in photos]
return result return result
@ -508,3 +535,91 @@ async def leave_walk(walk_id: int, user=Depends(get_current_user)):
conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,))
return {"status": "left", "teilnehmer_count": count} return {"status": "left", "teilnehmer_count": count}
# ------------------------------------------------------------------
# POST /api/walks/{id}/photos — Foto nach dem Treffen hochladen
# GET /api/walks/{id}/photos — Fotos eines Treffens abrufen
# ------------------------------------------------------------------
@router.post("/{walk_id}/photos", status_code=201)
async def upload_walk_photo(
walk_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user)
):
with db() as conn:
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
if not walk:
raise HTTPException(404, "Treffen nicht gefunden.")
# Nur Teilnehmer oder Veranstalter dürfen Fotos hochladen
is_participant = conn.execute(
"SELECT 1 FROM walk_participants WHERE walk_id=? AND user_id=?",
(walk_id, user['id'])
).fetchone()
if not is_participant and walk['user_id'] != user['id']:
raise HTTPException(403, "Nur Teilnehmer können Fotos hochladen.")
import io
from PIL import Image, ImageOps
try:
import pillow_heif; pillow_heif.register_heif_opener()
except ImportError:
pass
raw = await file.read()
try:
img = Image.open(io.BytesIO(raw))
img = ImageOps.exif_transpose(img).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=88)
raw = buf.getvalue()
except Exception:
pass
import re as _re
walk_datum = walk['datum'] or "0000-00-00" # YYYY-MM-DD
uname_raw = (user.get('name') or 'user').lower()
uname_safe = _re.sub(r'[^a-z0-9]', '-', uname_raw)[:20].strip('-')
with db() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM walk_photos WHERE walk_id=? AND user_id=?",
(walk_id, user['id'])
).fetchone()[0]
filename = f"{walk_datum}-{uname_safe}-{count + 1:03d}.jpg"
path = os.path.join(MEDIA_DIR, "walks", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
url = f"/media/walks/{filename}"
with db() as conn:
cur = conn.execute(
"INSERT INTO walk_photos (walk_id, user_id, url) VALUES (?,?,?)",
(walk_id, user['id'], url)
)
row = conn.execute("SELECT * FROM walk_photos WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.get("/{walk_id}/photos")
async def get_walk_photos(walk_id: int):
with db() as conn:
rows = conn.execute(
"SELECT * FROM walk_photos WHERE walk_id=? ORDER BY created_at",
(walk_id,)
).fetchall()
return [dict(r) for r in rows]
@router.delete("/{walk_id}/photos/{photo_id}", status_code=204)
async def delete_walk_photo(walk_id: int, photo_id: int, user=Depends(get_current_user)):
with db() as conn:
photo = conn.execute(
"SELECT * FROM walk_photos WHERE id=? AND walk_id=?", (photo_id, walk_id)
).fetchone()
if not photo:
raise HTTPException(404)
walk = conn.execute("SELECT user_id FROM walks WHERE id=?", (walk_id,)).fetchone()
if photo['user_id'] != user['id'] and walk['user_id'] != user['id']:
raise HTTPException(403)
conn.execute("DELETE FROM walk_photos WHERE id=?", (photo_id,))

View file

@ -3539,6 +3539,50 @@ html.modal-open {
#walks-map-view { #walks-map-view {
overflow: hidden; overflow: hidden;
} }
/* ── Desktop ≥1024px: Liste links, Karte rechts nebeneinander ── */
@media (min-width: 1024px) {
/* Tab-Treffen: horizontal splitten */
#walks-tab-treffen {
flex-direction: row;
overflow: hidden;
}
/* Toolbar über beiden Spalten → eigene Zeile */
#walks-tab-treffen .by-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
padding: var(--space-2) var(--space-4);
}
/* Platz für die fixe Toolbar oben */
#walks-tab-treffen {
padding-top: 52px;
position: relative;
}
/* Liste: feste Breite links, scrollbar */
#walks-list-view {
display: block !important;
width: 380px;
min-width: 320px;
flex-shrink: 0;
border-right: 1px solid var(--c-border);
overflow-y: auto;
}
/* Karte: Rest des Platzes, sticky */
#walks-map-view {
display: block !important;
flex: 1;
min-width: 0;
}
/* Liste/Karte-Toggle auf Desktop ausblenden */
.walks-view-toggle {
display: none;
}
}
.walks-participant { .walks-participant {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -101,9 +101,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=877"> <link rel="stylesheet" href="/css/design-system.css?v=883">
<link rel="stylesheet" href="/css/layout.css?v=877"> <link rel="stylesheet" href="/css/layout.css?v=883">
<link rel="stylesheet" href="/css/components.css?v=877"> <link rel="stylesheet" href="/css/components.css?v=883">
</head> </head>
<body> <body>
@ -583,10 +583,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=877"></script> <script src="/js/api.js?v=883"></script>
<script src="/js/ui.js?v=877"></script> <script src="/js/ui.js?v=883"></script>
<script src="/js/app.js?v=877"></script> <script src="/js/app.js?v=883"></script>
<script src="/js/worlds.js?v=877"></script> <script src="/js/worlds.js?v=883"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -346,6 +346,9 @@ const API = (() => {
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
participants(id) { return get(`/walks/${id}/participants`); }, participants(id) { return get(`/walks/${id}/participants`); },
photos(id) { return get(`/walks/${id}/photos`); },
uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); },
deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '877'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '883'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {
<p style="${S.p};margin-top:var(--space-3)"> <p style="${S.p};margin-top:var(--space-3)">
Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird
<strong>Claude Sonnet 4.6</strong> von Anthropic, PBC (San Francisco, USA) genutzt. <strong>Claude Sonnet 4.6</strong> von Anthropic, PBC (San Francisco, USA) genutzt.
In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt In diesem Fall wird der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und
keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie,
EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). Medikamente, Allergien) als Teil des Anfragetextes umfassen. Die Übermittlung
in die USA erfolgt auf Basis der EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO).
Datenschutzerklärung von Anthropic: Datenschutzerklärung von Anthropic:
<a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener" <a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener"
style="${S.a}">anthropic.com/privacy</a>. style="${S.a}">anthropic.com/privacy</a>.
</p> </p>
<p style="${S.p};margin-top:var(--space-3)">
Die <strong>Rassenerkennung per Foto</strong> sendet das hochgeladene Bild direkt an
Claude von Anthropic (USA) zur Analyse es gibt hierfür keinen lokalen Fallback.
Das Foto wird nicht dauerhaft bei Anthropic gespeichert. Rechtsgrundlage: Einwilligung
gem. Art. 6 Abs. 1 lit. a DSGVO durch aktive Nutzung der Funktion.
</p>
<p style="${S.p};margin-top:var(--space-3)"> <p style="${S.p};margin-top:var(--space-3)">
Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen. (Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
@ -122,19 +129,31 @@ window.Page_datenschutz = (() => {
findet nicht statt. findet nicht statt.
</p>`)} </p>`)}
${sec('Wetterdaten (Open-Meteo)', ` ${sec('Wetterdaten & Kartendienste', `
<p style="${S.p}"> <p style="${S.p}">
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform), um die lokale <strong>Open-Meteo</strong> (Österreich, DSGVO-konform) für die Wettervorhersage.
Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von
keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen <strong>OpenWeatherMap</strong> (OpenWeather Ltd., UK/USA) geladen dabei wird
Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst. dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt.
Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO. Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
</p> </p>
<p style="${S.p};margin-top:var(--space-3)"> <p style="${S.p};margin-top:var(--space-3)">
Datenschutzerklärung von Open-Meteo: Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine
GPS-Koordinaten serverseitig an <strong>Nominatim</strong> der OpenStreetMap Foundation
(UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben keine
personenbezogenen Daten.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Datenschutzerklärung Open-Meteo:
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener" <a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
style="${S.a}">open-meteo.com/en/terms</a> style="${S.a}">open-meteo.com/en/terms</a> ·
OpenWeatherMap:
<a href="https://openweathermap.org/privacy-policy" target="_blank" rel="noopener"
style="${S.a}">openweathermap.org/privacy-policy</a> ·
OpenStreetMap/Nominatim:
<a href="https://osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener"
style="${S.a}">osmfoundation.org</a>
</p>`)} </p>`)}
${sec('Routenvorschläge (OpenRouteService)', ` ${sec('Routenvorschläge (OpenRouteService)', `
@ -200,7 +219,16 @@ window.Page_datenschutz = (() => {
(Art. 16), <strong>Löschung</strong> (Art. 17), <strong>Einschränkung der Verarbeitung</strong> (Art. 16), <strong>Löschung</strong> (Art. 17), <strong>Einschränkung der Verarbeitung</strong>
(Art. 18) sowie <strong>Datenportabilität</strong> (Art. 20). Erteilte Einwilligungen (Art. 18) sowie <strong>Datenportabilität</strong> (Art. 20). Erteilte Einwilligungen
kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO). kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO).
Zur Ausübung deiner Rechte wende dich per E-Mail an </p>
<p style="${S.p};margin-top:var(--space-3)">
<strong>Datenexport (Art. 20 DSGVO):</strong> Du kannst jederzeit unter
Einstellungen Meine Daten exportieren" eine vollständige Kopie deiner
gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten,
Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Zur Ausübung weiterer Rechte wende dich per E-Mail an
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br> <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br>
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:<br> Beschwerde einzulegen:<br>
@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', ` ${sec('Speicherdauer', `
<p style="${S.p}"> <p style="${S.p}">
Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst
werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
(Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
erhalten, sofern sie für die Community relevant sind. Server-Logs werden nach 30 Tagen rotiert.
</p>`)} </p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0"> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 Stand: Mai 2026 · Version 2
</p> </p>
</div> </div>

View file

@ -294,6 +294,15 @@ window.Page_settings = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Abmelden Abmelden
</button> </button>
<button id="settings-export-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4);
border-radius:var(--radius-md);border:none;
background:none;color:var(--c-text-secondary);
font-size:var(--text-xs);cursor:pointer">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Meine Daten exportieren (DSGVO Art. 20)
</button>
<button id="settings-delete-account-btn" <button id="settings-delete-account-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center; style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4); gap:var(--space-2);padding:var(--space-2) var(--space-4);
@ -892,6 +901,32 @@ window.Page_settings = (() => {
_render(); _render();
}); });
document.getElementById('settings-export-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('settings-export-btn');
await UI.asyncButton(btn, async () => {
try {
const resp = await fetch('/api/profile/export', {
credentials: 'include',
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
});
if (!resp.ok) throw new Error('Export fehlgeschlagen.');
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banyaro-export-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
UI.toast.success('Export heruntergeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Export.');
}
});
});
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => { document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title: 'Konto unwiderruflich löschen?', title: 'Konto unwiderruflich löschen?',

View file

@ -55,6 +55,8 @@ window.Page_walks = (() => {
_container = container; _container = container;
_appState = appState; _appState = appState;
_render(); _render();
// Desktop: Leaflet sofort laden damit Karte bereit ist wenn Daten kommen
if (window.innerWidth >= 1024) UI.loadLeaflet();
try { _userPos = await API.getLocation(); } catch {} try { _userPos = await API.getLocation(); } catch {}
_loadData(); _loadData();
} }
@ -101,8 +103,9 @@ window.Page_walks = (() => {
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p> <p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div> </div>
</div> </div>
<!-- Karte --> <!-- Karte (auf Desktop immer sichtbar via CSS) -->
<div id="walks-map-view" class="walks-content" style="display:none"> <div id="walks-map-view" class="walks-content"
style="${window.innerWidth >= 1024 ? '' : 'display:none'}">
<div id="walks-map" class="walks-map"></div> <div id="walks-map" class="walks-map"></div>
</div> </div>
</div> </div>
@ -393,14 +396,31 @@ window.Page_walks = (() => {
const isInvited = !!myRsvp; const isInvited = !!myRsvp;
const invitations = participantData?.invitations ?? []; const invitations = participantData?.invitations ?? [];
// Teilnehmerliste (join-Teilnehmer, klassisch) // Teilnehmerliste mit Hundefotos
const teilnehmerHTML = walk.teilnehmer?.length const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => ` ? walk.teilnehmer.map(t => {
const dogsHTML = (t.hunde_liste || []).map(d => {
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
style="width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;border:1.5px solid var(--c-border)">`
: `<div style="width:28px;height:28px;border-radius:50%;background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1.5px solid var(--c-border)">
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
</div>`;
return `<div style="display:flex;align-items:center;gap:4px">
${av}
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}</span>
</div>`;
}).join('');
return `
<div class="walks-participant"> <div class="walks-participant">
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div> <div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<span class="walks-participant-name">${UI.escape(t.user_name)}</span> <div style="flex:1;min-width:0">
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${UI.escape(t.hunde)}</span>` : ''} <div class="walks-participant-name">${UI.escape(t.user_name)}</div>
</div>`).join('') ${dogsHTML ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:4px">${dogsHTML}</div>` : ''}
</div>
</div>`;
}).join('')
: ''; : '';
// Einladungsliste // Einladungsliste
@ -468,6 +488,31 @@ window.Page_walks = (() => {
<div id="wd-rating-${walk.id}"></div> <div id="wd-rating-${walk.id}"></div>
</div> </div>
<!-- Fotos nach dem Treffen -->
<div class="walks-detail-section" id="wd-photos-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('images')} Fotos</div>
${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? `
<label style="cursor:pointer">
<input type="file" id="wd-photo-input" accept="image/*" style="display:none">
<span class="btn btn-secondary btn-sm">${UI.icon('camera')} Foto hinzufügen</span>
</label>` : ''}
</div>
<div id="wd-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:var(--space-2)">
${(walk.photos || []).length === 0
? `<p style="grid-column:1/-1;color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos.</p>`
: (walk.photos || []).map(p => `
<div style="position:relative;aspect-ratio:1">
<img src="${UI.escape(p.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
${p.user_id === _appState.user?.id || isOwn ? `
<button type="button" class="wd-photo-del" data-photo-id="${p.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>
` : ''}
</div>`).join('')}
</div>
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)"> <p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')} Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
</p> </p>
@ -525,6 +570,49 @@ window.Page_walks = (() => {
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close); document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
// Foto-Upload
document.getElementById('wd-photo-input')?.addEventListener('change', async function() {
if (!this.files.length) return;
const file = this.files[0];
const formData = new FormData();
formData.append('file', file);
try {
const photo = await API.walks.uploadPhoto(walk.id, formData);
const grid = document.getElementById('wd-photos-grid');
if (grid) {
grid.querySelector('p')?.remove();
const div = document.createElement('div');
div.style.cssText = 'position:relative;aspect-ratio:1';
div.innerHTML = `
<img src="${UI.escape(photo.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
<button type="button" class="wd-photo-del" data-photo-id="${photo.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>`;
grid.appendChild(div);
_bindPhotoDel(walk.id, div);
UI.toast.success('Foto hochgeladen.');
}
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
this.value = '';
});
// Foto löschen — alle bestehenden Buttons
function _bindPhotoDel(walkId, container) {
container.querySelectorAll('.wd-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Foto löschen?')) return;
try {
await API.walks.deletePhoto(walkId, parseInt(btn.dataset.photoId));
btn.closest('[style*="aspect-ratio"]')?.remove();
UI.toast.success('Foto gelöscht.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}
_bindPhotoDel(walk.id, document);
document.getElementById('wd-login')?.addEventListener('click', () => { document.getElementById('wd-login')?.addEventListener('click', () => {
UI.modal.close(); UI.modal.close();
App.navigate('settings'); App.navigate('settings');

View file

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