Compare commits
No commits in common. "33f550a313d9d26fc1b1e2bfe9a0894ca0524ada" and "b6a644ac3a7baa4f67efa409ae16e210481b7c10" have entirely different histories.
33f550a313
...
b6a644ac3a
12 changed files with 58 additions and 548 deletions
|
|
@ -2210,22 +2210,6 @@ 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("""
|
||||||
|
|
|
||||||
|
|
@ -354,59 +354,29 @@ _MIME_MAP = {
|
||||||
".webm": "video/webm", ".pdf": "application/pdf",
|
".webm": "video/webm", ".pdf": "application/pdf",
|
||||||
}
|
}
|
||||||
|
|
||||||
from fastapi import Request as _Request
|
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
||||||
from fastapi.responses import FileResponse as _FileResponse
|
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
|
||||||
from auth import decode_token as _decode_token
|
from fastapi.responses import FileResponse as _FileResponse
|
||||||
|
|
||||||
# Pfade die Login erfordern (kein DB-Lookup — UUID in Dateiname schützt ausreichend)
|
def _media_response(filepath: str):
|
||||||
_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"])
|
||||||
def _resolve_media_path(path: str) -> str | None:
|
async def serve_media_staging(path: str):
|
||||||
primary = os.path.join(MEDIA_DIR, path)
|
staging_file = os.path.join(MEDIA_DIR, path)
|
||||||
if os.path.isfile(primary):
|
if os.path.isfile(staging_file):
|
||||||
return primary
|
return _media_response(staging_file)
|
||||||
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
prod_file = os.path.join(PROD_MEDIA_DIR, path)
|
||||||
fallback = os.path.join(PROD_MEDIA_DIR, path)
|
if os.path.isfile(prod_file):
|
||||||
if os.path.isfile(fallback):
|
return _media_response(prod_file)
|
||||||
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")
|
||||||
|
|
||||||
prefix = path.split("/")[0] + "/"
|
APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
# 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():
|
||||||
|
|
|
||||||
|
|
@ -167,134 +167,3 @@ 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}"'},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
"""BAN YARO — Gassi-Treffen"""
|
"""BAN YARO — Gassi-Treffen"""
|
||||||
|
|
||||||
import math, os, uuid
|
import math
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -373,34 +371,9 @@ 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'] = [
|
result['teilnehmer'] = [dict(p) for p in participants]
|
||||||
{**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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -535,91 +508,3 @@ 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,))
|
|
||||||
|
|
|
||||||
|
|
@ -3539,50 +3539,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -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=883">
|
<link rel="stylesheet" href="/css/design-system.css?v=877">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=883">
|
<link rel="stylesheet" href="/css/layout.css?v=877">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=883">
|
<link rel="stylesheet" href="/css/components.css?v=877">
|
||||||
</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=883"></script>
|
<script src="/js/api.js?v=877"></script>
|
||||||
<script src="/js/ui.js?v=883"></script>
|
<script src="/js/ui.js?v=877"></script>
|
||||||
<script src="/js/app.js?v=883"></script>
|
<script src="/js/app.js?v=877"></script>
|
||||||
<script src="/js/worlds.js?v=883"></script>
|
<script src="/js/worlds.js?v=877"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,9 +346,6 @@ 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}`); },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '883'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '877'; // ← 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
|
||||||
|
|
|
||||||
|
|
@ -102,20 +102,13 @@ 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 der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und
|
In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt —
|
||||||
Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie,
|
keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der
|
||||||
Medikamente, Allergien) als Teil des Anfragetextes umfassen. Die Übermittlung
|
EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO).
|
||||||
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.
|
||||||
|
|
@ -129,31 +122,19 @@ window.Page_datenschutz = (() => {
|
||||||
findet nicht statt.
|
findet nicht statt.
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Wetterdaten & Kartendienste', `
|
${sec('Wetterdaten (Open-Meteo)', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an
|
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an
|
||||||
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform) für die Wettervorhersage.
|
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform), um die lokale
|
||||||
Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von
|
Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen —
|
||||||
<strong>OpenWeatherMap</strong> (OpenWeather Ltd., UK/USA) geladen — dabei wird
|
keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen
|
||||||
dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt.
|
Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst.
|
||||||
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)">
|
||||||
Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine
|
Datenschutzerklärung von Open-Meteo:
|
||||||
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)', `
|
||||||
|
|
@ -219,16 +200,7 @@ 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).
|
||||||
</p>
|
Zur Ausübung deiner Rechte wende dich per E-Mail an
|
||||||
<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>
|
||||||
|
|
@ -240,14 +212,14 @@ window.Page_datenschutz = (() => {
|
||||||
|
|
||||||
${sec('Speicherdauer', `
|
${sec('Speicherdauer', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
|
Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs
|
||||||
einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
|
werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte
|
||||||
Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
|
(Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert
|
||||||
Server-Logs werden nach 30 Tagen rotiert.
|
erhalten, sofern sie für die Community relevant sind.
|
||||||
</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 · Version 2
|
Stand: Mai 2026
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -294,15 +294,6 @@ 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);
|
||||||
|
|
@ -901,32 +892,6 @@ 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?',
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
@ -103,9 +101,8 @@ 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 (auf Desktop immer sichtbar via CSS) -->
|
<!-- Karte -->
|
||||||
<div id="walks-map-view" class="walks-content"
|
<div id="walks-map-view" class="walks-content" style="display:none">
|
||||||
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>
|
||||||
|
|
@ -396,31 +393,14 @@ window.Page_walks = (() => {
|
||||||
const isInvited = !!myRsvp;
|
const isInvited = !!myRsvp;
|
||||||
const invitations = participantData?.invitations ?? [];
|
const invitations = participantData?.invitations ?? [];
|
||||||
|
|
||||||
// Teilnehmerliste mit Hundefotos
|
// Teilnehmerliste (join-Teilnehmer, klassisch)
|
||||||
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>
|
||||||
<div style="flex:1;min-width:0">
|
<span class="walks-participant-name">${UI.escape(t.user_name)}</span>
|
||||||
<div class="walks-participant-name">${UI.escape(t.user_name)}</div>
|
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${UI.escape(t.hunde)}</span>` : ''}
|
||||||
${dogsHTML ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:4px">${dogsHTML}</div>` : ''}
|
</div>`).join('')
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('')
|
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Einladungsliste
|
// Einladungsliste
|
||||||
|
|
@ -488,31 +468,6 @@ 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>
|
||||||
|
|
@ -570,49 +525,6 @@ 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');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v883';
|
const CACHE_VERSION = 'by-v877';
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue