Compare commits
No commits in common. "bda61a0e40fa15941bf762b121b6c5078e9ffd09" and "d18c592ef0fdb9c3d201c469157846f47223bfdd" have entirely different histories.
bda61a0e40
...
d18c592ef0
33 changed files with 273 additions and 2576 deletions
|
|
@ -189,13 +189,6 @@ def init_db():
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS route_dogs (
|
|
||||||
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (route_id, dog_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS exercise_progress (
|
CREATE TABLE IF NOT EXISTS exercise_progress (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
@ -1981,38 +1974,6 @@ def _migrate(conn_factory):
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: futter_profil bereit.")
|
logger.info("Migration: futter_profil bereit.")
|
||||||
|
|
||||||
# Futter-Einträge & Reaktionen (Verträglichkeits-Tracking)
|
|
||||||
try:
|
|
||||||
conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS futter_eintraege (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
datum TEXT NOT NULL,
|
|
||||||
uhrzeit TEXT NOT NULL,
|
|
||||||
futter_name TEXT NOT NULL,
|
|
||||||
futter_typ TEXT NOT NULL DEFAULT 'trockenfutter',
|
|
||||||
menge_g INTEGER,
|
|
||||||
notiz TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS futter_reaktionen (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
datum TEXT NOT NULL,
|
|
||||||
uhrzeit TEXT NOT NULL,
|
|
||||||
reaktion_typ TEXT NOT NULL,
|
|
||||||
intensitaet INTEGER NOT NULL DEFAULT 3,
|
|
||||||
notiz TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC);
|
|
||||||
""")
|
|
||||||
logger.info("Migration: futter_eintraege + futter_reaktionen bereit.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration futter_eintraege/reaktionen: {e}")
|
|
||||||
|
|
||||||
# Wiederkehrende Ausgaben (Daueraufträge)
|
# Wiederkehrende Ausgaben (Daueraufträge)
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
||||||
|
|
@ -2143,85 +2104,6 @@ def _migrate(conn_factory):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Spalte existiert bereits
|
pass # Spalte existiert bereits
|
||||||
|
|
||||||
# exercise_progress + training_plan_progress: dog_id ergänzen
|
|
||||||
existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()]
|
|
||||||
if 'dog_id' not in existing_ep:
|
|
||||||
try:
|
|
||||||
# Neue Tabelle mit dog_id erstellen
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE exercise_progress_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
exercise_id TEXT NOT NULL,
|
|
||||||
status TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(dog_id, exercise_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
# Bestehende Daten migrieren: dog_id = erster Hund des Users
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at)
|
|
||||||
SELECT ep.user_id,
|
|
||||||
(SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1),
|
|
||||||
ep.exercise_id, ep.status, ep.updated_at
|
|
||||||
FROM exercise_progress ep
|
|
||||||
""")
|
|
||||||
conn.execute("DROP TABLE exercise_progress")
|
|
||||||
conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)")
|
|
||||||
logger.info("Migration: exercise_progress.dog_id hinzugefügt.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()]
|
|
||||||
if 'dog_id' not in existing_tp:
|
|
||||||
try:
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE training_plan_progress_new (
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
item_key TEXT NOT NULL,
|
|
||||||
checked INTEGER NOT NULL DEFAULT 1,
|
|
||||||
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (dog_id, item_key)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at)
|
|
||||||
SELECT tp.user_id,
|
|
||||||
(SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1),
|
|
||||||
tp.item_key, tp.checked, tp.checked_at
|
|
||||||
FROM training_plan_progress tp
|
|
||||||
""")
|
|
||||||
conn.execute("DROP TABLE training_plan_progress")
|
|
||||||
conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress")
|
|
||||||
logger.info("Migration: training_plan_progress.dog_id hinzugefügt.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
# verstorben_am: Hund als verstorben markierbar
|
|
||||||
try:
|
|
||||||
conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT")
|
|
||||||
logger.info("Migration: dogs.verstorben_am hinzugefügt.")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
|
|
||||||
try:
|
|
||||||
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
|
|
||||||
if existing == 0:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT OR IGNORE INTO route_dogs (route_id, dog_id)
|
|
||||||
SELECT r.id, d.id
|
|
||||||
FROM routes r
|
|
||||||
JOIN dogs d ON d.user_id = r.user_id
|
|
||||||
""")
|
|
||||||
logger.info("Migration: route_dogs mit bestehenden Routen befüllt.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_help_articles(conn):
|
def _seed_help_articles(conn):
|
||||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||||
|
|
|
||||||
|
|
@ -179,9 +179,6 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path.startswith('/media/'):
|
if request.url.path.startswith('/media/'):
|
||||||
if os.getenv('STAGING') == 'true':
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
else:
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -253,7 +250,6 @@ from routes.ernaehrung import router as ernaehrung_router
|
||||||
from routes.challenges import router as challenges_router
|
from routes.challenges import router as challenges_router
|
||||||
from routes.gassi_zeiten import router as gassi_zeiten_router
|
from routes.gassi_zeiten import router as gassi_zeiten_router
|
||||||
from routes.help import router as help_router
|
from routes.help import router as help_router
|
||||||
from routes.feedback import router as feedback_router
|
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -316,7 +312,6 @@ app.include_router(ernaehrung_router, prefix="/api/dogs", tag
|
||||||
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
|
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
|
||||||
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
|
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
|
||||||
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
||||||
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -344,39 +339,9 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
|
||||||
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
|
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
|
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||||
|
|
||||||
STAGING = os.getenv("STAGING", "false").lower() == "true"
|
APP_VER = "826" # muss mit APP_VER in app.js übereinstimmen
|
||||||
PROD_MEDIA_DIR = "/prod-media"
|
|
||||||
|
|
||||||
_MIME_MAP = {
|
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
||||||
".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4",
|
|
||||||
".webm": "video/webm", ".pdf": "application/pdf",
|
|
||||||
}
|
|
||||||
|
|
||||||
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
|
||||||
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
|
|
||||||
from fastapi.responses import FileResponse as _FileResponse
|
|
||||||
|
|
||||||
def _media_response(filepath: str):
|
|
||||||
ext = os.path.splitext(filepath)[1].lower()
|
|
||||||
mt = _MIME_MAP.get(ext, "application/octet-stream")
|
|
||||||
return _FileResponse(filepath, media_type=mt)
|
|
||||||
|
|
||||||
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
|
|
||||||
async def serve_media_staging(path: str):
|
|
||||||
staging_file = os.path.join(MEDIA_DIR, path)
|
|
||||||
if os.path.isfile(staging_file):
|
|
||||||
return _media_response(staging_file)
|
|
||||||
prod_file = os.path.join(PROD_MEDIA_DIR, path)
|
|
||||||
if os.path.isfile(prod_file):
|
|
||||||
return _media_response(prod_file)
|
|
||||||
from fastapi import HTTPException as _HE
|
|
||||||
raise _HE(404, "Media not found")
|
|
||||||
else:
|
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
|
||||||
|
|
||||||
APP_VER = "872" # 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():
|
||||||
|
|
|
||||||
|
|
@ -583,48 +583,6 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/admin/referrals — User-wirbt-User Top 100
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/referrals")
|
|
||||||
async def referral_stats(user=Depends(require_mod)):
|
|
||||||
with db() as conn:
|
|
||||||
# Top-Werber mit Anzahl
|
|
||||||
top = conn.execute("""
|
|
||||||
SELECT r.id, r.name, r.email,
|
|
||||||
COUNT(u.id) AS invited_count,
|
|
||||||
r.created_at AS member_since
|
|
||||||
FROM users u
|
|
||||||
JOIN users r ON r.id = u.referred_by
|
|
||||||
GROUP BY r.id
|
|
||||||
ORDER BY invited_count DESC
|
|
||||||
LIMIT 100
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
# Alle Einladungen (für Detail-Ansicht)
|
|
||||||
invites = conn.execute("""
|
|
||||||
SELECT u.id, u.name, u.email, u.created_at,
|
|
||||||
r.id AS referrer_id, r.name AS referrer_name
|
|
||||||
FROM users u
|
|
||||||
JOIN users r ON r.id = u.referred_by
|
|
||||||
ORDER BY u.created_at DESC
|
|
||||||
LIMIT 500
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
||||||
total_referred = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL"
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"top_referrers": [dict(r) for r in top],
|
|
||||||
"recent_invites": [dict(r) for r in invites],
|
|
||||||
"total_users": total_users,
|
|
||||||
"total_referred": total_referred,
|
|
||||||
"viral_factor": round(total_referred / max(total_users - total_referred, 1), 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
|
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, has_pro_access
|
from auth import get_current_user
|
||||||
from routes.push import send_push_to_user
|
from routes.push import send_push_to_user
|
||||||
from media_utils import safe_media_path, preview_url_from
|
from media_utils import safe_media_path, preview_url_from
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ class DogUpdate(BaseModel):
|
||||||
async def list_dogs(user=Depends(get_current_user)):
|
async def list_dogs(user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
own = conn.execute(
|
own = conn.execute(
|
||||||
"SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) ORDER BY id",
|
"SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? ORDER BY id",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
shared = conn.execute(
|
shared = conn.execute(
|
||||||
|
|
@ -131,14 +131,6 @@ def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
if existing >= 1 and not has_pro_access(user):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Mehrere Hunde sind ein Pro-Feature. Upgrade auf Ban Yaro Pro, um weitere Hunde anzulegen."
|
|
||||||
)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
|
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
|
||||||
gewicht_kg, chip_nr, bio, is_public)
|
gewicht_kg, chip_nr, bio, is_public)
|
||||||
|
|
@ -188,7 +180,8 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
|
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
||||||
|
# Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
|
|
@ -197,13 +190,12 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
# Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen)
|
# Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
|
||||||
if not photos:
|
if not photos:
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
WHERE d.dog_id=? AND dm.media_type='image'
|
||||||
AND dm.img_width IS NULL
|
|
||||||
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -255,16 +247,15 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days
|
day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days
|
||||||
|
|
||||||
# Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden)
|
# Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden)
|
||||||
# Nur Übungen des aktiven Hundes, 'sitzt' ausschließen
|
|
||||||
try:
|
try:
|
||||||
joined = conn.execute(
|
joined = conn.execute(
|
||||||
"""SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw,
|
"""SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw,
|
||||||
te.schwierigkeit, te.js_exercise_id
|
te.schwierigkeit, te.js_exercise_id
|
||||||
FROM exercise_progress ep
|
FROM exercise_progress ep
|
||||||
JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id
|
JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id
|
||||||
WHERE ep.dog_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
|
WHERE ep.user_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
|
||||||
ORDER BY ep.updated_at ASC LIMIT 50""",
|
ORDER BY ep.updated_at ASC LIMIT 50""",
|
||||||
(dog_id,)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except Exception:
|
except Exception:
|
||||||
joined = []
|
joined = []
|
||||||
|
|
@ -289,9 +280,9 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
)
|
)
|
||||||
raw = conn.execute(
|
raw = conn.execute(
|
||||||
"""SELECT exercise_id FROM exercise_progress
|
"""SELECT exercise_id FROM exercise_progress
|
||||||
WHERE dog_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
|
WHERE user_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
|
||||||
ORDER BY updated_at ASC LIMIT 50""",
|
ORDER BY updated_at ASC LIMIT 50""",
|
||||||
(dog_id,)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
valid = [r["exercise_id"] for r in raw
|
valid = [r["exercise_id"] for r in raw
|
||||||
if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)]
|
if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)]
|
||||||
|
|
@ -780,21 +771,6 @@ async def get_hunde_buch(
|
||||||
return HTMLResponse(content=html_page)
|
return HTMLResponse(content=html_page)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/dogs/verstorben — Alle verstorbenen Hunde des Users
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/verstorben")
|
|
||||||
async def get_verstorbene_hunde(user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""SELECT id, name, rasse, foto_url, verstorben_am, geburtstag
|
|
||||||
FROM dogs WHERE user_id=? AND verstorben_am IS NOT NULL
|
|
||||||
ORDER BY verstorben_am DESC""",
|
|
||||||
(user["id"],)
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{dog_id}")
|
@router.get("/{dog_id}")
|
||||||
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
@ -1224,15 +1200,14 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
|
||||||
"ref_id": r["id"],
|
"ref_id": r["id"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# --- Routen (nur Routen wo dieser Hund mitgegangen ist) ---
|
# --- Routen ---
|
||||||
route_rows = conn.execute(
|
route_rows = conn.execute(
|
||||||
"""SELECT r.id, r.name, r.distanz_km,
|
"""SELECT id, name, distanz_km,
|
||||||
date(r.created_at) AS datum
|
date(created_at) AS datum
|
||||||
FROM routes r
|
FROM routes
|
||||||
JOIN route_dogs rd ON rd.route_id = r.id AND rd.dog_id = ?
|
WHERE user_id=?
|
||||||
WHERE r.user_id = ?
|
ORDER BY created_at ASC""",
|
||||||
ORDER BY r.created_at ASC""",
|
(user["id"],)
|
||||||
(dog_id, user["id"])
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
route_first = True
|
route_first = True
|
||||||
|
|
@ -1280,108 +1255,3 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
|
||||||
"geburtstag": dog["geburtstag"],
|
"geburtstag": dog["geburtstag"],
|
||||||
"events": events,
|
"events": events,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class GedenkenData(BaseModel):
|
|
||||||
verstorben_am: str # YYYY-MM-DD
|
|
||||||
|
|
||||||
@router.post("/{dog_id}/gedenken")
|
|
||||||
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
updated = conn.execute(
|
|
||||||
"UPDATE dogs SET verstorben_am=? WHERE id=? AND user_id=?",
|
|
||||||
(data.verstorben_am, dog_id, user["id"])
|
|
||||||
).rowcount
|
|
||||||
if not updated:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/dogs/{id}/gedenkseite — Memorial-Daten
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}/gedenkseite")
|
|
||||||
async def get_gedenkseite(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404)
|
|
||||||
dog = dict(dog)
|
|
||||||
|
|
||||||
# Statistiken
|
|
||||||
km_total = conn.execute(
|
|
||||||
"SELECT COALESCE(ROUND(SUM(distanz_km),1),0) AS km FROM routes r "
|
|
||||||
"JOIN route_dogs rd ON rd.route_id=r.id WHERE rd.dog_id=?", (dog_id,)
|
|
||||||
).fetchone()["km"]
|
|
||||||
|
|
||||||
diary_count = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM diary WHERE dog_id=?", (dog_id,)
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
media_count = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM diary_media dm JOIN diary d ON d.id=dm.diary_id "
|
|
||||||
"WHERE d.dog_id=? AND dm.media_type='image'", (dog_id,)
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
training_count = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM training_sessions WHERE dog_id=?", (dog_id,)
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
# Letzter Tagebucheintrag
|
|
||||||
last_entry = conn.execute(
|
|
||||||
"SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
# Erste und letzte Aufnahme
|
|
||||||
first_entry = conn.execute(
|
|
||||||
"SELECT datum FROM diary WHERE dog_id=? ORDER BY datum ASC LIMIT 1",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
# Letzte 6 Fotos für Galerie
|
|
||||||
photos = conn.execute(
|
|
||||||
"""SELECT dm.url FROM diary_media dm
|
|
||||||
JOIN diary d ON d.id=dm.diary_id
|
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
|
||||||
AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
|
|
||||||
ORDER BY d.datum DESC, dm.id DESC LIMIT 6""",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
if not photos:
|
|
||||||
photos = conn.execute(
|
|
||||||
"""SELECT dm.url FROM diary_media dm
|
|
||||||
JOIN diary d ON d.id=dm.diary_id
|
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
|
||||||
ORDER BY d.datum DESC, dm.id DESC LIMIT 6""",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
# Gemeinsame Zeit berechnen
|
|
||||||
joined = dog.get("geburtstag") or (first_entry["datum"] if first_entry else None)
|
|
||||||
passed = dog.get("verstorben_am")
|
|
||||||
gemeinsam_tage = None
|
|
||||||
if joined and passed:
|
|
||||||
try:
|
|
||||||
from datetime import date as _date
|
|
||||||
d1 = _date.fromisoformat(joined)
|
|
||||||
d2 = _date.fromisoformat(passed)
|
|
||||||
gemeinsam_tage = (d2 - d1).days
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"dog": dog,
|
|
||||||
"km_total": km_total,
|
|
||||||
"diary_count": diary_count,
|
|
||||||
"media_count": media_count,
|
|
||||||
"training_count": training_count,
|
|
||||||
"last_entry": dict(last_entry) if last_entry else None,
|
|
||||||
"gemeinsam_tage": gemeinsam_tage,
|
|
||||||
"photos": [r["url"] for r in photos],
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -143,305 +143,3 @@ async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
|
||||||
raise HTTPException(503, str(e))
|
raise HTTPException(503, str(e))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
||||||
|
|
||||||
|
|
||||||
# ==================================================================
|
|
||||||
# FUTTER-VERTRÄGLICHKEIT
|
|
||||||
# ==================================================================
|
|
||||||
|
|
||||||
REAKTION_TYPEN = {
|
|
||||||
# Positiv
|
|
||||||
"verdauung_gut": {"label": "Gute Verdauung", "kategorie": "positiv", "fenster_h": 8},
|
|
||||||
"energie_hoch": {"label": "Viel Energie", "kategorie": "positiv", "fenster_h": 12},
|
|
||||||
"fell_glaenzend": {"label": "Glänzendes Fell", "kategorie": "positiv", "fenster_h": 336}, # 2 Wochen
|
|
||||||
# Gastrointestinal
|
|
||||||
"erbrechen": {"label": "Erbrechen", "kategorie": "gastro_negativ", "fenster_h": 6},
|
|
||||||
"durchfall": {"label": "Durchfall", "kategorie": "gastro_negativ", "fenster_h": 8},
|
|
||||||
"blaehungen": {"label": "Blähungen", "kategorie": "gastro_negativ", "fenster_h": 6},
|
|
||||||
"weicher_stuhl": {"label": "Weicher Stuhl", "kategorie": "gastro_negativ", "fenster_h": 8},
|
|
||||||
"appetitlosigkeit":{"label": "Appetitlosigkeit", "kategorie": "gastro_negativ", "fenster_h": 12},
|
|
||||||
# Haut & Fell
|
|
||||||
"juckreiz": {"label": "Juckreiz / Kratzen", "kategorie": "haut_negativ", "fenster_h": 72},
|
|
||||||
"haarausfall": {"label": "Haarausfall", "kategorie": "haut_negativ", "fenster_h": 336},
|
|
||||||
"stumpfes_fell": {"label": "Stumpfes Fell", "kategorie": "haut_negativ", "fenster_h": 336},
|
|
||||||
"schuppenbildung":{"label": "Schuppenbildung", "kategorie": "haut_negativ", "fenster_h": 168},
|
|
||||||
"roetungen": {"label": "Hautrötungen / Entzündung", "kategorie": "haut_negativ", "fenster_h": 72},
|
|
||||||
"pfotenlecken": {"label": "Pfoten lecken (chronisch)", "kategorie": "haut_negativ", "fenster_h": 168},
|
|
||||||
"ohrentzuendung": {"label": "Ohrentzündung", "kategorie": "haut_negativ", "fenster_h": 168},
|
|
||||||
"fettiges_fell": {"label": "Fettiges Fell / Seborrhö", "kategorie": "haut_negativ", "fenster_h": 336},
|
|
||||||
# Allgemeinbefinden
|
|
||||||
"schlappheit": {"label": "Schlappheit / Apathie", "kategorie": "allgemein_negativ", "fenster_h": 12},
|
|
||||||
"nervositaet": {"label": "Nervosität / Unruhe", "kategorie": "allgemein_negativ", "fenster_h": 12},
|
|
||||||
"viel_trinken": {"label": "Ungewöhnlich viel trinken", "kategorie": "allgemein_negativ", "fenster_h": 24},
|
|
||||||
"sonstiges": {"label": "Sonstiges", "kategorie": "sonstiges", "fenster_h": 24},
|
|
||||||
}
|
|
||||||
|
|
||||||
_POSITIV_KAT = {"positiv"}
|
|
||||||
_NEGATIV_KAT = {"gastro_negativ", "haut_negativ", "allgemein_negativ"}
|
|
||||||
_HAUT_HINWEIS = "Haut- & Fell-Symptome wie {label} entwickeln sich typischerweise über Wochen. Mindestens 4–6 Wochen Beobachtung empfohlen — auch nach einem Futterwechsel dauert eine Besserung 2–6 Wochen."
|
|
||||||
_GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb weniger Stunden auf. Wenn sie häufig wiederkehren, ist ein Tierarztbesuch empfohlen."
|
|
||||||
|
|
||||||
|
|
||||||
class FutterEintragCreate(BaseModel):
|
|
||||||
datum: str
|
|
||||||
uhrzeit: str
|
|
||||||
futter_name: str
|
|
||||||
futter_typ: Optional[str] = "trockenfutter"
|
|
||||||
menge_g: Optional[int] = None
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ReaktionCreate(BaseModel):
|
|
||||||
datum: str
|
|
||||||
uhrzeit: str
|
|
||||||
reaktion_typ: str
|
|
||||||
intensitaet: Optional[int] = 3
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /dogs/{dog_id}/futter
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/{dog_id}/futter")
|
|
||||||
async def create_futter_eintrag(dog_id: int, body: FutterEintragCreate,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
cur = conn.execute("""
|
|
||||||
INSERT INTO futter_eintraege
|
|
||||||
(dog_id, datum, uhrzeit, futter_name, futter_typ, menge_g, notiz)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (dog_id, body.datum, body.uhrzeit, body.futter_name,
|
|
||||||
body.futter_typ or "trockenfutter", body.menge_g, body.notiz))
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM futter_eintraege WHERE id=?", (cur.lastrowid,)
|
|
||||||
).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /dogs/{dog_id}/futter
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}/futter")
|
|
||||||
async def list_futter_eintraege(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT * FROM futter_eintraege
|
|
||||||
WHERE dog_id=?
|
|
||||||
ORDER BY datum DESC, uhrzeit DESC
|
|
||||||
LIMIT 50
|
|
||||||
""", (dog_id,)).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /dogs/{dog_id}/futter/{entry_id}
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{dog_id}/futter/{entry_id}")
|
|
||||||
async def delete_futter_eintrag(dog_id: int, entry_id: int,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
result = conn.execute(
|
|
||||||
"DELETE FROM futter_eintraege WHERE id=? AND dog_id=?",
|
|
||||||
(entry_id, dog_id)
|
|
||||||
)
|
|
||||||
if result.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /dogs/{dog_id}/futter/reaktion
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/{dog_id}/futter/reaktion")
|
|
||||||
async def create_reaktion(dog_id: int, body: ReaktionCreate,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
cur = conn.execute("""
|
|
||||||
INSERT INTO futter_reaktionen
|
|
||||||
(dog_id, datum, uhrzeit, reaktion_typ, intensitaet, notiz)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""", (dog_id, body.datum, body.uhrzeit, body.reaktion_typ,
|
|
||||||
body.intensitaet or 3, body.notiz))
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM futter_reaktionen WHERE id=?", (cur.lastrowid,)
|
|
||||||
).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /dogs/{dog_id}/futter/reaktionen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}/futter/reaktionen")
|
|
||||||
async def list_reaktionen(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT * FROM futter_reaktionen
|
|
||||||
WHERE dog_id=?
|
|
||||||
ORDER BY datum DESC, uhrzeit DESC
|
|
||||||
LIMIT 50
|
|
||||||
""", (dog_id,)).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /dogs/{dog_id}/futter/reaktion/{react_id}
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{dog_id}/futter/reaktion/{react_id}")
|
|
||||||
async def delete_reaktion(dog_id: int, react_id: int,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
result = conn.execute(
|
|
||||||
"DELETE FROM futter_reaktionen WHERE id=? AND dog_id=?",
|
|
||||||
(react_id, dog_id)
|
|
||||||
)
|
|
||||||
if result.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Reaktion nicht gefunden.")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /dogs/{dog_id}/futter/analyse
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}/futter/analyse")
|
|
||||||
async def futter_analyse(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_access(conn, dog_id, user["id"])
|
|
||||||
|
|
||||||
eintraege = conn.execute(
|
|
||||||
"SELECT * FROM futter_eintraege WHERE dog_id=? ORDER BY datum, uhrzeit",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
reaktionen = conn.execute(
|
|
||||||
"SELECT * FROM futter_reaktionen WHERE dog_id=? ORDER BY datum, uhrzeit",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
def parse_ts(datum, uhrzeit):
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(f"{datum}T{uhrzeit}")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# futter_name → {typ, mahlzeiten, positiv, negativ, kategorien: {kat: count}}
|
|
||||||
futter_stats: dict = {}
|
|
||||||
|
|
||||||
for e in eintraege:
|
|
||||||
name = e["futter_name"]
|
|
||||||
if name not in futter_stats:
|
|
||||||
futter_stats[name] = {
|
|
||||||
"name": name,
|
|
||||||
"typ": e["futter_typ"],
|
|
||||||
"mahlzeiten": 0,
|
|
||||||
"positiv": 0,
|
|
||||||
"negativ": 0,
|
|
||||||
"kategorien": {},
|
|
||||||
}
|
|
||||||
futter_stats[name]["mahlzeiten"] += 1
|
|
||||||
|
|
||||||
for r in reaktionen:
|
|
||||||
r_ts = parse_ts(r["datum"], r["uhrzeit"])
|
|
||||||
if not r_ts:
|
|
||||||
continue
|
|
||||||
r_typ = r["reaktion_typ"]
|
|
||||||
meta = REAKTION_TYPEN.get(r_typ, {"kategorie": "sonstiges", "fenster_h": 24})
|
|
||||||
kat = meta["kategorie"]
|
|
||||||
fenster = meta["fenster_h"]
|
|
||||||
# Mindestfenster 1h, maximales Fenster wie angegeben
|
|
||||||
min_h = 1
|
|
||||||
|
|
||||||
for e in eintraege:
|
|
||||||
e_ts = parse_ts(e["datum"], e["uhrzeit"])
|
|
||||||
if not e_ts:
|
|
||||||
continue
|
|
||||||
diff = (r_ts - e_ts).total_seconds() / 3600
|
|
||||||
if min_h <= diff <= fenster:
|
|
||||||
name = e["futter_name"]
|
|
||||||
if name not in futter_stats:
|
|
||||||
continue
|
|
||||||
if kat in _POSITIV_KAT:
|
|
||||||
futter_stats[name]["positiv"] += 1
|
|
||||||
elif kat in _NEGATIV_KAT:
|
|
||||||
futter_stats[name]["negativ"] += 1
|
|
||||||
# Kategorie-Zähler
|
|
||||||
futter_stats[name]["kategorien"][kat] = \
|
|
||||||
futter_stats[name]["kategorien"].get(kat, 0) + 1
|
|
||||||
|
|
||||||
result_futter = []
|
|
||||||
for stats in futter_stats.values():
|
|
||||||
positiv = stats["positiv"]
|
|
||||||
negativ = stats["negativ"]
|
|
||||||
total = positiv + negativ
|
|
||||||
if total == 0:
|
|
||||||
score = 50
|
|
||||||
status = "neu"
|
|
||||||
else:
|
|
||||||
raw = (positiv - negativ * 2) / max(1, total)
|
|
||||||
# raw liegt zwischen -2 und 1 → normieren auf 0-100
|
|
||||||
score = int(max(0, min(100, (raw + 2) / 3 * 100)))
|
|
||||||
if score >= 60:
|
|
||||||
status = "gut"
|
|
||||||
elif score >= 30:
|
|
||||||
status = "neutral"
|
|
||||||
else:
|
|
||||||
status = "problematisch"
|
|
||||||
|
|
||||||
result_futter.append({
|
|
||||||
"name": stats["name"],
|
|
||||||
"typ": stats["typ"],
|
|
||||||
"mahlzeiten": stats["mahlzeiten"],
|
|
||||||
"positiv": positiv,
|
|
||||||
"negativ": negativ,
|
|
||||||
"score": score,
|
|
||||||
"status": status,
|
|
||||||
"kategorien": stats["kategorien"],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sortierung: problematisch → neutral → gut → neu, dann nach Score
|
|
||||||
ORDER = {"problematisch": 0, "neutral": 1, "gut": 2, "neu": 3}
|
|
||||||
result_futter.sort(key=lambda x: (ORDER.get(x["status"], 9), -x["score"]))
|
|
||||||
|
|
||||||
# Hinweis ableiten: erstes problematisches Futter mit Haut/Gastro-Symptomen
|
|
||||||
hinweis = None
|
|
||||||
for f in result_futter:
|
|
||||||
if f["status"] != "problematisch":
|
|
||||||
continue
|
|
||||||
kats = f["kategorien"]
|
|
||||||
if kats.get("haut_negativ", 0) > 0:
|
|
||||||
# Häufigstes Haut-Symptom finden
|
|
||||||
haut_rxn = [
|
|
||||||
r["reaktion_typ"] for r in reaktionen
|
|
||||||
if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "haut_negativ"
|
|
||||||
]
|
|
||||||
label = REAKTION_TYPEN.get(haut_rxn[0], {}).get("label", "Haut-Symptome") if haut_rxn else "Haut-Symptome"
|
|
||||||
hinweis = _HAUT_HINWEIS.format(label=label)
|
|
||||||
break
|
|
||||||
if kats.get("gastro_negativ", 0) > 0:
|
|
||||||
gastro_rxn = [
|
|
||||||
r["reaktion_typ"] for r in reaktionen
|
|
||||||
if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "gastro_negativ"
|
|
||||||
]
|
|
||||||
label = REAKTION_TYPEN.get(gastro_rxn[0], {}).get("label", "Magen-Darm-Symptome") if gastro_rxn else "Magen-Darm-Symptome"
|
|
||||||
hinweis = _GASTRO_HINWEIS.format(label=label)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Kategorien-Übersicht über alle Reaktionen
|
|
||||||
kategorien_gesamt: dict = {}
|
|
||||||
for r in reaktionen:
|
|
||||||
kat = REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie", "sonstiges")
|
|
||||||
kategorien_gesamt[kat] = kategorien_gesamt.get(kat, 0) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"eintraege_count": len(eintraege),
|
|
||||||
"reaktionen_count": len(reaktionen),
|
|
||||||
"futter": result_futter,
|
|
||||||
"kategorien": kategorien_gesamt,
|
|
||||||
"hinweis": hinweis,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""
|
|
||||||
BAN YARO — User-Feedback per E-Mail an support@banyaro.app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from auth import get_current_user
|
|
||||||
from mailer import send_email, email_html
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
SUPPORT_MAIL = "support@banyaro.app"
|
|
||||||
|
|
||||||
KATEGORIEN = {"bug": "🐛 Bug / Fehler", "idee": "💡 Idee / Wunsch", "lob": "🎉 Lob", "sonstiges": "💬 Sonstiges"}
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackIn(BaseModel):
|
|
||||||
kategorie: Literal["bug", "idee", "lob", "sonstiges"]
|
|
||||||
text: Annotated[str, Field(min_length=5, max_length=2000)]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def submit_feedback(
|
|
||||||
payload: FeedbackIn,
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
kat_label = KATEGORIEN.get(payload.kategorie, payload.kategorie)
|
|
||||||
username = user.get("name", "?")
|
|
||||||
email = user.get("email", "")
|
|
||||||
tier = user.get("subscription_tier", "standard")
|
|
||||||
|
|
||||||
subject = f"[Feedback] {kat_label} von @{username}"
|
|
||||||
|
|
||||||
body = f"""
|
|
||||||
<p style="margin:0 0 16px">
|
|
||||||
Neues Feedback aus der App:
|
|
||||||
</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
|
|
||||||
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600;width:120px">Kategorie</td>
|
|
||||||
<td style="padding:6px 10px;border-bottom:1px solid #eee">{kat_label}</td></tr>
|
|
||||||
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">User</td>
|
|
||||||
<td style="padding:6px 10px;border-bottom:1px solid #eee">@{username} ({email})</td></tr>
|
|
||||||
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">Tier</td>
|
|
||||||
<td style="padding:6px 10px;border-bottom:1px solid #eee">{tier}</td></tr>
|
|
||||||
</table>
|
|
||||||
<div style="background:#fdf6ef;border-left:4px solid #C4843A;padding:14px 16px;
|
|
||||||
border-radius:0 8px 8px 0;white-space:pre-wrap;font-size:14px;line-height:1.6">
|
|
||||||
{payload.text}
|
|
||||||
</div>"""
|
|
||||||
|
|
||||||
plain = f"Feedback [{kat_label}] von @{username} ({email})\n\n{payload.text}"
|
|
||||||
|
|
||||||
await send_email(SUPPORT_MAIL, subject, email_html(body), plain)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
@ -172,20 +172,6 @@ async def send_request(target_id: int, user=Depends(get_current_user)):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending")
|
|
||||||
async def pending_requests(user=Depends(get_current_user)):
|
|
||||||
"""Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept."""
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT f.id, u.name AS requester_name, u.avatar_url
|
|
||||||
FROM friendships f
|
|
||||||
JOIN users u ON u.id = f.requester_id
|
|
||||||
WHERE f.addressee_id=? AND f.status='pending'
|
|
||||||
ORDER BY f.created_at DESC
|
|
||||||
""", (user["id"],)).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{friendship_id}/accept")
|
@router.post("/{friendship_id}/accept")
|
||||||
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
|
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
system=system,
|
system=system,
|
||||||
max_tokens=600,
|
max_tokens=600,
|
||||||
requires_premium=True,
|
requires_premium=False,
|
||||||
user_id=user["id"],
|
user_id=user["id"],
|
||||||
)
|
)
|
||||||
return {"antwort": result}
|
return {"antwort": result}
|
||||||
|
|
@ -361,65 +361,3 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
||||||
"hinweis": parsed.get("hinweis") or None,
|
"hinweis": parsed.get("hinweis") or None,
|
||||||
"verbleibende_anfragen": remaining_after,
|
"verbleibende_anfragen": remaining_after,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class AbschiedRequest(BaseModel):
|
|
||||||
dog_id: int
|
|
||||||
name: str
|
|
||||||
rasse: Optional[str] = None
|
|
||||||
km_total: Optional[float] = None
|
|
||||||
diary_count: Optional[int] = None
|
|
||||||
gemeinsam_tage: Optional[int] = None
|
|
||||||
last_entry_titel: Optional[str] = None
|
|
||||||
|
|
||||||
@router.post("/abschied")
|
|
||||||
async def ki_abschied(req: AbschiedRequest, request: Request,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
"""Persönlicher Abschiedstext — einmalig generiert, DB-gecacht."""
|
|
||||||
with db() as conn:
|
|
||||||
cached = conn.execute(
|
|
||||||
"SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'",
|
|
||||||
(req.dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
if cached:
|
|
||||||
return {"text": cached["content"], "cached": True}
|
|
||||||
|
|
||||||
name = req.name.strip()[:40]
|
|
||||||
rasse = req.rasse or ""
|
|
||||||
km = f"{req.km_total:.0f} km" if req.km_total else None
|
|
||||||
tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None
|
|
||||||
eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None
|
|
||||||
|
|
||||||
stats_str = ", ".join(filter(None, [km, tage, eintr]))
|
|
||||||
rasse_str = f" ({rasse})" if rasse else ""
|
|
||||||
|
|
||||||
system = (
|
|
||||||
"Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. "
|
|
||||||
"Schreibe warmherzig, persönlich und respektvoll auf Deutsch. "
|
|
||||||
"Keine Floskeln, kein Kitsch — echte Wärme. "
|
|
||||||
"Erwähne die Statistiken natürlich eingebunden."
|
|
||||||
)
|
|
||||||
prompt = (
|
|
||||||
f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. "
|
|
||||||
f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80–100 Wörter) "
|
|
||||||
f"der die Verbundenheit würdigt. "
|
|
||||||
f"Statistiken: {stats_str or 'nicht bekannt'}. "
|
|
||||||
f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
text = await ki_module.complete(
|
|
||||||
system=system, prompt=prompt, max_tokens=300,
|
|
||||||
requires_premium=False, user_id=user["id"],
|
|
||||||
)
|
|
||||||
with db() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)",
|
|
||||||
(req.dog_id, text)
|
|
||||||
)
|
|
||||||
return {"text": text, "cached": False}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(503, str(e))
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ class RouteCreate(BaseModel):
|
||||||
is_public: Optional[bool] = False
|
is_public: Optional[bool] = False
|
||||||
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
||||||
client_time: Optional[str] = None
|
client_time: Optional[str] = None
|
||||||
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
|
|
||||||
|
|
||||||
class RouteUpdate(BaseModel):
|
class RouteUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
@ -70,9 +69,6 @@ class RouteUpdate(BaseModel):
|
||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
hunde_tauglichkeit: Optional[str] = None
|
hunde_tauglichkeit: Optional[str] = None
|
||||||
|
|
||||||
class RouteDogs(BaseModel):
|
|
||||||
dog_ids: List[int]
|
|
||||||
|
|
||||||
|
|
||||||
def _simplify_track(track: list, max_pts: int = 40) -> list:
|
def _simplify_track(track: list, max_pts: int = 40) -> list:
|
||||||
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
|
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
|
||||||
|
|
@ -172,26 +168,7 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
|
||||||
int(data.is_public) if data.is_public is not None else 1,
|
int(data.is_public) if data.is_public is not None else 1,
|
||||||
data.hunde_tauglichkeit, is_valid, ct,
|
data.hunde_tauglichkeit, is_valid, ct,
|
||||||
))
|
))
|
||||||
route_id = cur.lastrowid
|
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||||
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
|
|
||||||
|
|
||||||
# Hunde zuordnen — entweder explizit oder alle Hunde des Users
|
|
||||||
dog_ids = data.dog_ids or []
|
|
||||||
if not dog_ids:
|
|
||||||
# Fallback: alle Hunde des Users
|
|
||||||
all_dogs = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE user_id=?", (user['id'],)
|
|
||||||
).fetchall()
|
|
||||||
dog_ids = [d['id'] for d in all_dogs]
|
|
||||||
for did in dog_ids:
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)",
|
|
||||||
(route_id, did)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
update_streak(user['id'], conn)
|
update_streak(user['id'], conn)
|
||||||
check_and_award(user['id'], conn)
|
check_and_award(user['id'], conn)
|
||||||
result = _parse(row)
|
result = _parse(row)
|
||||||
|
|
@ -218,10 +195,6 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
|
||||||
if not ORS_API_KEY:
|
if not ORS_API_KEY:
|
||||||
raise HTTPException(503, "ORS nicht konfiguriert")
|
raise HTTPException(503, "ORS nicht konfiguriert")
|
||||||
|
|
||||||
from auth import has_pro_access
|
|
||||||
if not has_pro_access(user):
|
|
||||||
raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.")
|
|
||||||
|
|
||||||
is_privileged = (
|
is_privileged = (
|
||||||
user.get("rolle") in ("admin", "moderator") or
|
user.get("rolle") in ("admin", "moderator") or
|
||||||
user.get("is_moderator") or
|
user.get("is_moderator") or
|
||||||
|
|
@ -342,12 +315,7 @@ async def get_route(route_id: int):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Route nicht gefunden.")
|
raise HTTPException(404, "Route nicht gefunden.")
|
||||||
dog_rows = conn.execute(
|
return _parse(row)
|
||||||
"SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,)
|
|
||||||
).fetchall()
|
|
||||||
result = _parse(row)
|
|
||||||
result['dog_ids'] = [r['dog_id'] for r in dog_rows]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -374,26 +342,6 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
|
||||||
return _parse(row)
|
return _parse(row)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.patch("/{route_id}/dogs")
|
|
||||||
async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Route nicht gefunden.")
|
|
||||||
if row['user_id'] != user['id']:
|
|
||||||
raise HTTPException(403, "Nicht berechtigt.")
|
|
||||||
conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,))
|
|
||||||
for did in data.dog_ids:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)",
|
|
||||||
(route_id, did)
|
|
||||||
)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
|
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -85,19 +85,12 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
class ProgressUpdate(BaseModel):
|
class ProgressUpdate(BaseModel):
|
||||||
exercise_id: str
|
exercise_id: str
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt
|
||||||
dog_id: Optional[int] = None
|
|
||||||
|
|
||||||
@router.get("/progress")
|
@router.get("/progress")
|
||||||
async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
async def get_progress(user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if dog_id:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
|
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
|
||||||
(uid,)
|
(uid,)
|
||||||
|
|
@ -108,18 +101,10 @@ async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_us
|
||||||
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if body.dog_id:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status)
|
|
||||||
VALUES (?,?,?,?)
|
|
||||||
ON CONFLICT(dog_id, exercise_id) DO UPDATE
|
|
||||||
SET status=excluded.status, updated_at=datetime('now')
|
|
||||||
""", (uid, body.dog_id, body.exercise_id, body.status))
|
|
||||||
else:
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO exercise_progress (user_id, exercise_id, status)
|
INSERT INTO exercise_progress (user_id, exercise_id, status)
|
||||||
VALUES (?,?,?)
|
VALUES (?,?,?)
|
||||||
ON CONFLICT(dog_id, exercise_id) DO UPDATE
|
ON CONFLICT(user_id, exercise_id) DO UPDATE
|
||||||
SET status=excluded.status, updated_at=datetime('now')
|
SET status=excluded.status, updated_at=datetime('now')
|
||||||
""", (uid, body.exercise_id, body.status))
|
""", (uid, body.exercise_id, body.status))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -130,18 +115,11 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
||||||
class PlanProgress(BaseModel):
|
class PlanProgress(BaseModel):
|
||||||
item_key: str
|
item_key: str
|
||||||
checked: bool
|
checked: bool
|
||||||
dog_id: Optional[int] = None
|
|
||||||
|
|
||||||
@router.get("/plan-progress")
|
@router.get("/plan-progress")
|
||||||
async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
async def get_plan_progress(user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if dog_id:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
|
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
|
||||||
(uid,)
|
(uid,)
|
||||||
|
|
@ -154,13 +132,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if body.checked:
|
if body.checked:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked)
|
INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked)
|
||||||
VALUES (?,?,?,1)
|
VALUES (?,?,1)
|
||||||
""", (uid, body.dog_id, body.item_key))
|
""", (uid, body.item_key))
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?",
|
"DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?",
|
||||||
(body.dog_id, body.item_key)
|
(uid, body.item_key)
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
@ -171,15 +149,9 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus /
|
||||||
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
|
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
|
||||||
|
|
||||||
@router.get("/suggestions")
|
@router.get("/suggestions")
|
||||||
async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
async def get_suggestions(user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if dog_id:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
|
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
|
||||||
(uid,)
|
(uid,)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ BAN YARO — Wetter-API
|
||||||
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Query, HTTPException, Depends
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
import weather as weather_module
|
import weather as weather_module
|
||||||
|
|
@ -12,34 +11,6 @@ from database import db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "")
|
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/radar-tiles')
|
|
||||||
async def radar_tile_config(user=Depends(get_current_user)):
|
|
||||||
"""Regenradar-Tile-Config (RainViewer)."""
|
|
||||||
return {"provider": "rainviewer"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/layer-tiles')
|
|
||||||
async def layer_tile_config(
|
|
||||||
layer: str = "temp_new",
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig)."""
|
|
||||||
if layer not in ALLOWED_OWM_LAYERS:
|
|
||||||
raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}")
|
|
||||||
if not OWM_API_KEY:
|
|
||||||
raise HTTPException(503, "OWM nicht konfiguriert.")
|
|
||||||
return {
|
|
||||||
"url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}",
|
|
||||||
"maxNativeZoom": 18,
|
|
||||||
"opacity": 0.6,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('')
|
@router.get('')
|
||||||
async def get_weather(
|
async def get_weather(
|
||||||
|
|
|
||||||
|
|
@ -3184,16 +3184,6 @@ html.modal-open {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
#map-radar-btn.active {
|
|
||||||
background: #1d4ed8;
|
|
||||||
border-color: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
#map-temp-btn.active {
|
|
||||||
background: #dc2626;
|
|
||||||
border-color: #dc2626;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.map-fab:disabled { opacity: 0.5; cursor: default; }
|
.map-fab:disabled { opacity: 0.5; cursor: default; }
|
||||||
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
|
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
|
||||||
@keyframes fab-spin { to { transform: rotate(360deg); } }
|
@keyframes fab-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
|
||||||
|
|
@ -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=872">
|
<link rel="stylesheet" href="/css/design-system.css?v=826">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=872">
|
<link rel="stylesheet" href="/css/layout.css?v=826">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=872">
|
<link rel="stylesheet" href="/css/components.css?v=826">
|
||||||
</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=872"></script>
|
<script src="/js/api.js?v=826"></script>
|
||||||
<script src="/js/ui.js?v=872"></script>
|
<script src="/js/ui.js?v=826"></script>
|
||||||
<script src="/js/app.js?v=872"></script>
|
<script src="/js/app.js?v=826"></script>
|
||||||
<script src="/js/worlds.js?v=872"></script>
|
<script src="/js/worlds.js?v=826"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,7 @@ const API = (() => {
|
||||||
try { data = await response.json(); } catch { data = null; }
|
try { data = await response.json(); } catch { data = null; }
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const _d = data?.detail;
|
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
||||||
const message = (typeof _d === 'string' ? _d
|
|
||||||
: Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe')
|
|
||||||
: null) || data?.message || `Fehler ${response.status}`;
|
|
||||||
const isSwOffline = response.status === 503 && message.startsWith('Offline');
|
const isSwOffline = response.status === 503 && message.startsWith('Offline');
|
||||||
|
|
||||||
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
|
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
|
||||||
|
|
@ -142,15 +139,6 @@ const API = (() => {
|
||||||
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
|
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
|
||||||
getSkills(id) { return get(`/dogs/${id}/skills`); },
|
getSkills(id) { return get(`/dogs/${id}/skills`); },
|
||||||
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
|
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
|
||||||
gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); },
|
|
||||||
gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); },
|
|
||||||
futterList(id) { return get(`/dogs/${id}/futter`); },
|
|
||||||
futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); },
|
|
||||||
futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); },
|
|
||||||
reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); },
|
|
||||||
reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); },
|
|
||||||
reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); },
|
|
||||||
futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -296,7 +284,6 @@ const API = (() => {
|
||||||
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
||||||
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
|
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
|
||||||
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
|
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
|
||||||
updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); },
|
|
||||||
addPhoto(id, file) {
|
addPhoto(id, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
|
|
@ -310,11 +297,11 @@ const API = (() => {
|
||||||
// TRAINING & ÜBUNGSFORTSCHRITT
|
// TRAINING & ÜBUNGSFORTSCHRITT
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const training = {
|
const training = {
|
||||||
getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
getProgress() { return get('/training/progress'); },
|
||||||
setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); },
|
setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); },
|
||||||
getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); },
|
getSuggestions() { return get('/training/suggestions'); },
|
||||||
getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
getPlanProgress() { return get('/training/plan-progress'); },
|
||||||
setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); },
|
setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); },
|
||||||
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
|
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -474,7 +461,6 @@ const API = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const friends = {
|
const friends = {
|
||||||
list() { return get('/friends/'); },
|
list() { return get('/friends/'); },
|
||||||
pending() { return get('/friends/pending'); },
|
|
||||||
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
||||||
activity() { return get('/friends/activity'); },
|
activity() { return get('/friends/activity'); },
|
||||||
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '872'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '826'; // ← 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.0'; // ← 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
|
||||||
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
||||||
|
|
@ -57,7 +57,7 @@ const App = (() => {
|
||||||
'erste-hilfe': { title: 'Erste Hilfe', module: null },
|
'erste-hilfe': { title: 'Erste Hilfe', module: null },
|
||||||
settings: { title: 'Einstellungen', module: null },
|
settings: { title: 'Einstellungen', module: null },
|
||||||
lost: { title: 'Verlorener Hund', module: null },
|
lost: { title: 'Verlorener Hund', module: null },
|
||||||
friends: { title: 'Freunde', module: null, requiresAuth: true },
|
friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true },
|
||||||
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
|
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
|
||||||
social: { title: 'Social Media', module: null, requiresAuth: true },
|
social: { title: 'Social Media', module: null, requiresAuth: true },
|
||||||
admin: { title: 'Admin', module: null, requiresAuth: true },
|
admin: { title: 'Admin', module: null, requiresAuth: true },
|
||||||
|
|
@ -198,26 +198,6 @@ const App = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init()
|
|
||||||
const pageEl = document.getElementById(`page-${pageId}`);
|
|
||||||
if (pageEl) {
|
|
||||||
pageEl.querySelector('#pro-role-banner')?.remove();
|
|
||||||
if (page.requiresPro && state.user) {
|
|
||||||
const t = state.user.subscription_tier || 'standard';
|
|
||||||
const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) &&
|
|
||||||
(state.user.rolle === 'admin' || state.user.rolle === 'moderator' ||
|
|
||||||
state.user.is_moderator || state.user.is_social_media);
|
|
||||||
if (isRoleBased) {
|
|
||||||
const banner = document.createElement('div');
|
|
||||||
banner.id = 'pro-role-banner';
|
|
||||||
banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;';
|
|
||||||
banner.innerHTML = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 256 256" fill="currentColor"><path d="M236.8,188.09,149.35,36.22a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>
|
|
||||||
⭐ Pro-Feature — Standard-User sehen diese Seite nicht`;
|
|
||||||
pageEl.insertBefore(banner, pageEl.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.module) {
|
if (page.module) {
|
||||||
const hasParams = params && Object.keys(params).length > 0;
|
const hasParams = params && Object.keys(params).length > 0;
|
||||||
if (hasParams) {
|
if (hasParams) {
|
||||||
|
|
@ -905,12 +885,6 @@ const App = (() => {
|
||||||
if (!dog || dog.id === state.activeDog?.id) return;
|
if (!dog || dog.id === state.activeDog?.id) return;
|
||||||
state.activeDog = dog;
|
state.activeDog = dog;
|
||||||
localStorage.setItem('by_active_dog', String(dogId));
|
localStorage.setItem('by_active_dog', String(dogId));
|
||||||
// SW-Cache für hund-spezifische Daten invalidieren
|
|
||||||
navigator.serviceWorker?.controller?.postMessage({
|
|
||||||
type: 'INVALIDATE_CACHE',
|
|
||||||
paths: ['/api/training/progress', '/api/training/plan-progress',
|
|
||||||
'/api/training/suggestions', `/api/dogs/${dogId}/welcome-dashboard`],
|
|
||||||
});
|
|
||||||
_renderDogSwitcher();
|
_renderDogSwitcher();
|
||||||
_notifyDogChange();
|
_notifyDogChange();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ window.Page_admin = (() => {
|
||||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||||
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
||||||
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
||||||
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -162,7 +161,6 @@ window.Page_admin = (() => {
|
||||||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||||
case 'hilfe': await _renderHilfe(el); break;
|
case 'hilfe': await _renderHilfe(el); break;
|
||||||
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
||||||
case 'referrals': await _renderReferrals(el); break;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||||
|
|
@ -536,22 +534,22 @@ window.Page_admin = (() => {
|
||||||
};
|
};
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="adm-stats-grid" id="adm-overview-grid">
|
<div class="adm-stats-grid">
|
||||||
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
|
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
|
||||||
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
|
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
|
||||||
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
|
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
|
||||||
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
|
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
|
||||||
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
|
${_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)', 'moderation')}
|
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
|
||||||
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
|
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
|
||||||
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
|
${_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)', 'system')}
|
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')}
|
||||||
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
|
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')}
|
||||||
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
|
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
|
||||||
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
|
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
|
||||||
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
|
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
|
||||||
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
|
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')}
|
||||||
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
|
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
|
|
@ -707,19 +705,11 @@ window.Page_admin = (() => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
|
|
||||||
const card = e.target.closest('[data-adm-tab]');
|
|
||||||
if (!card) return;
|
|
||||||
const tab = card.dataset.admTab;
|
|
||||||
_container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _statCard(icon, label, value, color, tab = null) {
|
function _statCard(icon, label, value, color) {
|
||||||
const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
|
|
||||||
return `
|
return `
|
||||||
<div class="card" ${clickable}>
|
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||||
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
|
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
|
@ -3346,79 +3336,6 @@ window.Page_admin = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: REFERRALS
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function _renderReferrals(el) {
|
|
||||||
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade…</div>`;
|
|
||||||
let d;
|
|
||||||
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger)">Fehler beim Laden.</div>`; return; }
|
|
||||||
|
|
||||||
const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0;
|
|
||||||
|
|
||||||
const topRows = d.top_referrers.map((r, i) => `
|
|
||||||
<tr>
|
|
||||||
<td style="padding:8px 10px;color:var(--c-text-muted);font-weight:600">${i + 1}</td>
|
|
||||||
<td style="padding:8px 10px;font-weight:600">${_esc(r.name)}</td>
|
|
||||||
<td style="padding:8px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.email)}</td>
|
|
||||||
<td style="padding:8px 10px;text-align:right">
|
|
||||||
<span style="font-size:var(--text-lg);font-weight:800;color:var(--c-primary)">${r.invited_count}</span>
|
|
||||||
</td>
|
|
||||||
</tr>`).join('');
|
|
||||||
|
|
||||||
const recentRows = d.recent_invites.slice(0, 50).map(r => `
|
|
||||||
<tr>
|
|
||||||
<td style="padding:6px 10px;font-weight:500">${_esc(r.name)}</td>
|
|
||||||
<td style="padding:6px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.referrer_name)}</td>
|
|
||||||
<td style="padding:6px 10px;color:var(--c-text-muted);font-size:var(--text-xs)">${(r.created_at || '').slice(0, 10)}</td>
|
|
||||||
</tr>`).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-4)">
|
|
||||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
|
||||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${d.total_referred}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Geworbene User</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
|
||||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success)">${pct}%</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Anteil geworbener User</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
|
||||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-warning)">${d.viral_factor}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Virality Factor</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
|
|
||||||
<div class="by-card-section-header">Top Werber</div>
|
|
||||||
<div style="overflow-x:auto">
|
|
||||||
<table style="width:100%;border-collapse:collapse">
|
|
||||||
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
<th style="padding:8px 10px;text-align:left">#</th>
|
|
||||||
<th style="padding:8px 10px;text-align:left">Name</th>
|
|
||||||
<th style="padding:8px 10px;text-align:left">E-Mail</th>
|
|
||||||
<th style="padding:8px 10px;text-align:right">Eingeladen</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${topRows || '<tr><td colspan="4" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Empfehlungen</td></tr>'}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="overflow:hidden">
|
|
||||||
<div class="by-card-section-header">Zuletzt geworbene User</div>
|
|
||||||
<div style="overflow-x:auto">
|
|
||||||
<table style="width:100%;border-collapse:collapse">
|
|
||||||
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
<th style="padding:6px 10px;text-align:left">User</th>
|
|
||||||
<th style="padding:6px 10px;text-align:left">Geworben von</th>
|
|
||||||
<th style="padding:6px 10px;text-align:left">Datum</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${recentRows || '<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Daten</td></tr>'}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,12 @@ window.Page_diary = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!_appState.activeDog) return;
|
if (!_appState.activeDog) return;
|
||||||
|
// Mehrere Hunde → Picker zeigen (User kann Hund wählen)
|
||||||
|
if (_appState.dogs.length > 1) {
|
||||||
|
_renderDogPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Einzelner Hund → Diary direkt neu laden
|
||||||
_offset = 0;
|
_offset = 0;
|
||||||
_entries = [];
|
_entries = [];
|
||||||
_totalStats = null;
|
_totalStats = null;
|
||||||
|
|
@ -188,15 +194,59 @@ window.Page_diary = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_appState.dogs.length > 1) {
|
||||||
|
_renderDogPicker();
|
||||||
|
} else {
|
||||||
await _renderDiary();
|
await _renderDiary();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderDogPicker() {
|
||||||
|
const activeDogId = _appState.activeDog?.id;
|
||||||
|
|
||||||
|
const cards = _appState.dogs.map(dog => {
|
||||||
|
const isActive = dog.id === activeDogId;
|
||||||
|
const av = dog.foto_url
|
||||||
|
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}">`
|
||||||
|
: `<span>${UI.icon('dog')}</span>`;
|
||||||
|
return `
|
||||||
|
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
||||||
|
data-dog-id="${dog.id}">
|
||||||
|
<div class="diary-picker-av">${av}</div>
|
||||||
|
<div class="diary-picker-name">${UI.escape(dog.name)}</div>
|
||||||
|
${dog.rasse ? `<div class="diary-picker-rasse">${UI.escape(dog.rasse)}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div class="diary-picker-wrap">
|
||||||
|
<p class="diary-picker-hint">Wessen Tagebuch?</p>
|
||||||
|
<div class="diary-picker-grid">${cards}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_container.querySelectorAll('.diary-picker-card').forEach(el => {
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(el.dataset.dogId);
|
||||||
|
if (id === _appState.activeDog?.id) {
|
||||||
|
// Bereits aktiver Hund → direkt Diary laden
|
||||||
|
_offset = 0; _entries = [];
|
||||||
|
await _renderDiary();
|
||||||
|
} else {
|
||||||
|
App.setActiveDog(id);
|
||||||
|
// onDogChange() → _renderDiary() via _notifyDogChange()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// DIARY-ANSICHT — Timeline mit Einträgen
|
// DIARY-ANSICHT — Timeline mit Einträgen
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _renderDiary() {
|
async function _renderDiary() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
${UI.dogChip(_appState)}
|
|
||||||
<div class="by-toolbar diary-toolbar">
|
<div class="by-toolbar diary-toolbar">
|
||||||
<div class="diary-search-wrap" id="diary-search-wrap">
|
<div class="diary-search-wrap" id="diary-search-wrap">
|
||||||
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||||
|
|
@ -224,7 +274,6 @@ window.Page_diary = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
UI.bindDogChip(_container, _appState);
|
|
||||||
|
|
||||||
_container.querySelector('#diary-milestone-filter')
|
_container.querySelector('#diary-milestone-filter')
|
||||||
?.addEventListener('click', async () => {
|
?.addEventListener('click', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1060,12 +1060,6 @@ window.Page_dog_profile = (() => {
|
||||||
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
|
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
|
||||||
<button type="button" id="dp-gedenken-btn"
|
|
||||||
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
|
||||||
border:none;background:#1a1a1a;color:#C4843A;
|
|
||||||
font-size:var(--text-sm);font-weight:600;cursor:pointer">
|
|
||||||
Verstorben
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1285,11 +1279,6 @@ window.Page_dog_profile = (() => {
|
||||||
document.getElementById('dp-form-cancel')
|
document.getElementById('dp-form-cancel')
|
||||||
?.addEventListener('click', UI.modal.close);
|
?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => {
|
|
||||||
UI.modal.close();
|
|
||||||
_openGedenkenFlow(dog);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
|
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title : `${dog.name} löschen?`,
|
title : `${dog.name} löschen?`,
|
||||||
|
|
@ -2425,178 +2414,6 @@ window.Page_dog_profile = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// ----------------------------------------------------------
|
|
||||||
// GEDENKEN-FLOW
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _openGedenkenFlow(dog) {
|
|
||||||
// Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe
|
|
||||||
UI.modal.open({
|
|
||||||
title: `Abschied von ${dog.name}`,
|
|
||||||
body: `
|
|
||||||
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
|
|
||||||
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary);opacity:0.7" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#heart"></use>
|
|
||||||
</svg>
|
|
||||||
<p style="color:var(--c-text-secondary);margin:var(--space-3) 0 var(--space-4);line-height:1.6">
|
|
||||||
${dog.name} hinterlässt eine riesige Lücke.<br>
|
|
||||||
Die gemeinsamen Erinnerungen bleiben für immer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form id="gedenken-form">
|
|
||||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
|
||||||
Datum des Abschieds
|
|
||||||
</label>
|
|
||||||
<input type="date" id="gedenken-datum" name="datum"
|
|
||||||
value="${new Date().toISOString().slice(0,10)}"
|
|
||||||
max="${new Date().toISOString().slice(0,10)}"
|
|
||||||
style="width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
|
||||||
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
|
|
||||||
</form>`,
|
|
||||||
footer: `
|
|
||||||
<div class="w3-btn-stack">
|
|
||||||
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary" style="width:100%">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
|
|
||||||
Gedenkseite erstellen
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
|
||||||
</div>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('gedenken-form')?.addEventListener('submit', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.getElementById('gedenken-save-btn');
|
|
||||||
const datum = document.getElementById('gedenken-datum').value;
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum });
|
|
||||||
// Aus aktiver Hundeliste entfernen
|
|
||||||
_appState.dogs = _appState.dogs.filter(d => d.id !== dog.id);
|
|
||||||
_appState.activeDog = _appState.dogs[0] || null;
|
|
||||||
UI.modal.close();
|
|
||||||
// Gedenkseite öffnen
|
|
||||||
await _openGedenkseite(dog.id, dog.name);
|
|
||||||
await _render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _openGedenkseite(dogId, dogName) {
|
|
||||||
UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: `
|
|
||||||
<div style="text-align:center;padding:var(--space-4)">
|
|
||||||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite">
|
|
||||||
<use href="/icons/phosphor.svg#spinner"></use>
|
|
||||||
</svg>
|
|
||||||
</div>` });
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
|
|
||||||
catch { UI.modal.close(); return; }
|
|
||||||
|
|
||||||
const d = data;
|
|
||||||
const av = d.dog.foto_url
|
|
||||||
? `<img src="${UI.escape(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
|
|
||||||
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
|
|
||||||
|
|
||||||
const photoGrid = d.photos.length ? `
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
|
|
||||||
${d.photos.map(url => `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
|
|
||||||
</div>` : '';
|
|
||||||
|
|
||||||
const statsHtml = `
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
|
|
||||||
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Trauer-Support-Texte
|
|
||||||
const supportHtml = `
|
|
||||||
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
|
|
||||||
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
|
|
||||||
<div style="font-weight:700;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
|
||||||
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
|
|
||||||
Für dich in dieser Zeit
|
|
||||||
</div>
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
|
|
||||||
Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein.
|
|
||||||
Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.8">
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
|
|
||||||
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
|
|
||||||
Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft.
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
|
|
||||||
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
|
||||||
Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte.
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2)">
|
|
||||||
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
|
|
||||||
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
|
|
||||||
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
|
||||||
Persönlichen Abschiedstext erstellen
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const modal = UI.modal.open({
|
|
||||||
title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`,
|
|
||||||
body: `
|
|
||||||
<div style="text-align:center;margin-bottom:var(--space-4)">
|
|
||||||
${av}
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;margin-top:var(--space-3)">${UI.escape(d.dog.name)}</div>
|
|
||||||
${d.dog.rasse ? `<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${UI.escape(d.dog.rasse)}</div>` : ''}
|
|
||||||
${d.dog.verstorben_am ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:4px">
|
|
||||||
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#rainbow"></use></svg>
|
|
||||||
Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')}
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
${photoGrid}
|
|
||||||
${statsHtml}
|
|
||||||
${supportHtml}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('gedenk-ki-btn');
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const result = await API.post('/ki/abschied', {
|
|
||||||
dog_id: dogId,
|
|
||||||
name: d.dog.name,
|
|
||||||
rasse: d.dog.rasse,
|
|
||||||
km_total: d.km_total,
|
|
||||||
diary_count: d.diary_count,
|
|
||||||
gemeinsam_tage: d.gemeinsam_tage,
|
|
||||||
});
|
|
||||||
const wrap = document.getElementById('gedenk-ki-wrap');
|
|
||||||
if (wrap) wrap.innerHTML = `
|
|
||||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-4);
|
|
||||||
font-size:var(--text-sm);line-height:1.7;color:var(--c-text);font-style:italic">
|
|
||||||
"${UI.escape(result.text)}"
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh, onDogChange, addNew: _openCreateModal };
|
return { init, refresh, onDogChange, addNew: _openCreateModal };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ window.Page_ernaehrung = (() => {
|
||||||
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
|
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
|
||||||
{ key: 'gift', label: 'Giftliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>' },
|
{ key: 'gift', label: 'Giftliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>' },
|
||||||
{ key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
|
{ key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
|
||||||
{ key: 'vertraeglichkeit', label: 'Verträglichkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -48,7 +47,6 @@ window.Page_ernaehrung = (() => {
|
||||||
|
|
||||||
async function onDogChange() {
|
async function onDogChange() {
|
||||||
_profil = {};
|
_profil = {};
|
||||||
_activeTab = 'rechner'; // Tab zurücksetzen damit neuer Hund frisch startet
|
|
||||||
await _render();
|
await _render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,12 +73,10 @@ window.Page_ernaehrung = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
|
|
||||||
<div class="by-tabs" id="ern-tabs"></div>
|
<div class="by-tabs" id="ern-tabs"></div>
|
||||||
<div id="ern-tab-content"></div>
|
<div id="ern-tab-content"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
UI.bindDogChip(_container, _appState);
|
|
||||||
_renderTabBar();
|
_renderTabBar();
|
||||||
_renderTab();
|
_renderTab();
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +110,6 @@ window.Page_ernaehrung = (() => {
|
||||||
case 'guide': _renderGuide(el); break;
|
case 'guide': _renderGuide(el); break;
|
||||||
case 'gift': _renderGift(el); break;
|
case 'gift': _renderGift(el); break;
|
||||||
case 'ki': _renderKi(el); break;
|
case 'ki': _renderKi(el); break;
|
||||||
case 'vertraeglichkeit': _renderVertraeglichkeit(el); break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,531 +630,6 @@ window.Page_ernaehrung = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB 5: VERTRÄGLICHKEIT
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function _renderVertraeglichkeit(el) {
|
|
||||||
const dog = _appState?.activeDog;
|
|
||||||
if (!dog) { el.innerHTML = ''; return; }
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="padding:var(--space-4) 0">
|
|
||||||
|
|
||||||
<!-- Schnell-Erfassung -->
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-bottom:var(--space-4)">
|
|
||||||
<button class="btn btn-primary" id="vert-btn-futter">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>
|
|
||||||
Futter erfassen
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="vert-btn-reaktion">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
|
|
||||||
Reaktion erfassen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info-Banner Haut/Fell -->
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
|
|
||||||
background:var(--c-surface);border:1px solid var(--c-border);
|
|
||||||
border-radius:var(--radius-md);padding:var(--space-3);
|
|
||||||
margin-bottom:var(--space-4);font-size:var(--text-xs);
|
|
||||||
color:var(--c-text-secondary);line-height:1.5">
|
|
||||||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary);margin-top:1px">
|
|
||||||
<use href="/icons/phosphor.svg#info"></use>
|
|
||||||
</svg>
|
|
||||||
<span>Haut- & Fellsymptome zeigen sich erst nach Wochen — trage regelmäßig ein um Muster zu erkennen.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Analyse -->
|
|
||||||
<div id="vert-analyse" style="margin-bottom:var(--space-5)">
|
|
||||||
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#circle-notch"></use></svg>
|
|
||||||
Lade Analyse…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verlauf -->
|
|
||||||
<div id="vert-verlauf"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
el.querySelector('#vert-btn-futter').addEventListener('click', () => _openFutterModal(el, dog));
|
|
||||||
el.querySelector('#vert-btn-reaktion').addEventListener('click', () => _openReaktionModal(el, dog));
|
|
||||||
|
|
||||||
await _loadAnalyse(el, dog);
|
|
||||||
await _loadVerlauf(el, dog);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _todayStr() {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
function _nowTimeStr() {
|
|
||||||
const d = new Date();
|
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _openFutterModal(el, dog) {
|
|
||||||
const id = `fm-${Date.now()}`;
|
|
||||||
const body = `
|
|
||||||
<form id="${id}">
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Datum</label>
|
|
||||||
<input type="date" name="datum" class="form-control by-input" value="${_todayStr()}" required>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Uhrzeit</label>
|
|
||||||
<input type="time" name="uhrzeit" class="form-control by-input" value="${_nowTimeStr()}" required>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Futter-Name</label>
|
|
||||||
<input type="text" name="futter_name" class="form-control by-input"
|
|
||||||
list="vert-futter-datalist" placeholder="z. B. Wolfsblut Adult" required>
|
|
||||||
<datalist id="vert-futter-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Futter-Typ</label>
|
|
||||||
<select name="futter_typ" class="form-control by-select">
|
|
||||||
<option value="trockenfutter">Trockenfutter</option>
|
|
||||||
<option value="nassfutter">Nassfutter</option>
|
|
||||||
<option value="barf">BARF</option>
|
|
||||||
<option value="snack">Snack</option>
|
|
||||||
<option value="sonstiges">Sonstiges</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Menge (g, optional)</label>
|
|
||||||
<input type="number" name="menge_g" class="form-control by-input" min="1" placeholder="">
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Notiz (optional)</label>
|
|
||||||
<textarea name="notiz" class="form-control by-input" rows="2" placeholder=""></textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
const footer = `
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="vert-futter-save-btn" form="${id}">Speichern</button>
|
|
||||||
`;
|
|
||||||
UI.modal.open({ title: 'Futter erfassen', body, footer });
|
|
||||||
|
|
||||||
// Datalist mit bekannten Futter-Namen füllen
|
|
||||||
API.dogs.futterList(dog.id).then(list => {
|
|
||||||
const dl = document.getElementById('vert-futter-datalist');
|
|
||||||
if (!dl) return;
|
|
||||||
const names = [...new Set((list || []).map(e => e.futter_name))];
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${_esc(n)}">`).join('');
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const saveBtn = document.getElementById('vert-futter-save-btn');
|
|
||||||
if (!saveBtn) return;
|
|
||||||
saveBtn.addEventListener('click', async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const form = document.getElementById(id);
|
|
||||||
if (!form) return;
|
|
||||||
const fd = new FormData(form);
|
|
||||||
const data = {
|
|
||||||
datum: fd.get('datum'),
|
|
||||||
uhrzeit: fd.get('uhrzeit'),
|
|
||||||
futter_name: (fd.get('futter_name') || '').trim(),
|
|
||||||
futter_typ: fd.get('futter_typ') || 'trockenfutter',
|
|
||||||
menge_g: fd.get('menge_g') ? parseInt(fd.get('menge_g')) : null,
|
|
||||||
notiz: (fd.get('notiz') || '').trim() || null,
|
|
||||||
};
|
|
||||||
if (!data.futter_name) { UI.toast.warning('Bitte Futter-Name angeben.'); return; }
|
|
||||||
await UI.asyncButton(saveBtn, async () => {
|
|
||||||
try {
|
|
||||||
await API.dogs.futterCreate(dog.id, data);
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Futter gespeichert.');
|
|
||||||
const tabEl = _container.querySelector('#ern-tab-content');
|
|
||||||
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message || 'Fehler beim Speichern.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _openReaktionModal(el, dog) {
|
|
||||||
const id = `rm-${Date.now()}`;
|
|
||||||
const body = `
|
|
||||||
<form id="${id}">
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Datum</label>
|
|
||||||
<input type="date" name="datum" class="form-control by-input" value="${_todayStr()}" required>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Uhrzeit</label>
|
|
||||||
<input type="time" name="uhrzeit" class="form-control by-input" value="${_nowTimeStr()}" required>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Reaktion</label>
|
|
||||||
<select name="reaktion_typ" class="form-control by-select" required>
|
|
||||||
<optgroup label="✓ Positiv">
|
|
||||||
<option value="fell_glaenzend">Glänzendes Fell</option>
|
|
||||||
<option value="verdauung_gut">Gute Verdauung</option>
|
|
||||||
<option value="energie_hoch">Viel Energie</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Magen & Darm">
|
|
||||||
<option value="erbrechen">Erbrechen</option>
|
|
||||||
<option value="durchfall">Durchfall</option>
|
|
||||||
<option value="blaehungen">Blähungen</option>
|
|
||||||
<option value="weicher_stuhl">Weicher Stuhl</option>
|
|
||||||
<option value="appetitlosigkeit">Appetitlosigkeit</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Haut & Fell">
|
|
||||||
<option value="juckreiz">Juckreiz / Kratzen</option>
|
|
||||||
<option value="haarausfall">Haarausfall</option>
|
|
||||||
<option value="stumpfes_fell">Stumpfes Fell</option>
|
|
||||||
<option value="schuppenbildung">Schuppenbildung</option>
|
|
||||||
<option value="roetungen">Hautrötungen / Entzündung</option>
|
|
||||||
<option value="pfotenlecken">Pfoten lecken (chronisch)</option>
|
|
||||||
<option value="ohrentzuendung">Ohrentzündung</option>
|
|
||||||
<option value="fettiges_fell">Fettiges Fell / Seborrhö</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Allgemeinbefinden">
|
|
||||||
<option value="schlappheit">Schlappheit / Apathie</option>
|
|
||||||
<option value="nervositaet">Nervosität / Unruhe</option>
|
|
||||||
<option value="viel_trinken">Ungewöhnlich viel trinken</option>
|
|
||||||
<option value="sonstiges">Sonstiges</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Intensität (1–5)</label>
|
|
||||||
<div class="vert-stern-gruppe" style="display:flex;gap:6px;flex-wrap:wrap">
|
|
||||||
${[1,2,3,4,5].map(n => `
|
|
||||||
<button type="button" class="vert-stern${n <= 3 ? ' active' : ''}"
|
|
||||||
data-val="${n}"
|
|
||||||
style="width:40px;height:40px;border-radius:8px;
|
|
||||||
border:1.5px solid var(--c-border);
|
|
||||||
background:${n <= 3 ? 'var(--c-primary)' : 'var(--c-bg-card)'};
|
|
||||||
color:${n <= 3 ? '#fff' : 'var(--c-text-secondary)'};
|
|
||||||
font-weight:700;cursor:pointer;">${n}</button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="intensitaet" value="3">
|
|
||||||
</div>
|
|
||||||
<div class="by-form-group">
|
|
||||||
<label class="by-label">Notiz (optional)</label>
|
|
||||||
<textarea name="notiz" class="form-control by-input" rows="2" placeholder=""></textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
const footer = `
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="vert-reaktion-save-btn" form="${id}">Speichern</button>
|
|
||||||
`;
|
|
||||||
UI.modal.open({ title: 'Reaktion erfassen', body, footer });
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// Stern-Buttons
|
|
||||||
document.querySelectorAll('.vert-stern').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const val = parseInt(btn.dataset.val);
|
|
||||||
const form = document.getElementById(id);
|
|
||||||
if (form) form.querySelector('[name=intensitaet]').value = val;
|
|
||||||
document.querySelectorAll('.vert-stern').forEach(b => {
|
|
||||||
const v = parseInt(b.dataset.val);
|
|
||||||
b.style.background = v <= val ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
|
||||||
b.style.color = v <= val ? '#fff' : 'var(--c-text-secondary)';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveBtn = document.getElementById('vert-reaktion-save-btn');
|
|
||||||
if (!saveBtn) return;
|
|
||||||
saveBtn.addEventListener('click', async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const form = document.getElementById(id);
|
|
||||||
if (!form) return;
|
|
||||||
const fd = new FormData(form);
|
|
||||||
const data = {
|
|
||||||
datum: fd.get('datum'),
|
|
||||||
uhrzeit: fd.get('uhrzeit'),
|
|
||||||
reaktion_typ: fd.get('reaktion_typ'),
|
|
||||||
intensitaet: parseInt(fd.get('intensitaet')) || 3,
|
|
||||||
notiz: (fd.get('notiz') || '').trim() || null,
|
|
||||||
};
|
|
||||||
await UI.asyncButton(saveBtn, async () => {
|
|
||||||
try {
|
|
||||||
await API.dogs.reaktionCreate(dog.id, data);
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Reaktion gespeichert.');
|
|
||||||
const tabEl = _container.querySelector('#ern-tab-content');
|
|
||||||
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message || 'Fehler beim Speichern.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadAnalyse(el, dog) {
|
|
||||||
const analyseEl = el.querySelector('#vert-analyse');
|
|
||||||
if (!analyseEl) return;
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await API.dogs.futterAnalyse(dog.id);
|
|
||||||
} catch (_) {
|
|
||||||
analyseEl.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Analyse nicht verfügbar.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.futter || data.futter.length === 0) {
|
|
||||||
analyseEl.innerHTML = `
|
|
||||||
<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
|
|
||||||
<svg class="ph-icon" style="width:2rem;height:2rem;margin-bottom:8px;display:block;margin-inline:auto" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#chart-bar"></use>
|
|
||||||
</svg>
|
|
||||||
Noch keine Einträge. Erfasse Futter und Reaktionen um die Analyse zu sehen.
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CFG = {
|
|
||||||
gut: { label: 'Gut verträglich', color: 'var(--c-success,#22c55e)', bg: 'rgba(34,197,94,0.12)' },
|
|
||||||
neutral: { label: 'Neutral', color: 'var(--c-warning,#f59e0b)', bg: 'rgba(245,158,11,0.12)' },
|
|
||||||
problematisch:{ label: 'Problematisch', color: 'var(--c-danger,#ef4444)', bg: 'rgba(239,68,68,0.12)' },
|
|
||||||
neu: { label: 'Zu wenig Daten', color: 'var(--c-text-muted)', bg: 'var(--c-surface)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYP_LABELS = {
|
|
||||||
trockenfutter: 'Trockenfutter', nassfutter: 'Nassfutter',
|
|
||||||
barf: 'BARF', snack: 'Snack', sonstiges: 'Sonstiges',
|
|
||||||
};
|
|
||||||
|
|
||||||
const KAT_LABELS = {
|
|
||||||
gastro_negativ: 'Magen & Darm',
|
|
||||||
haut_negativ: 'Haut & Fell',
|
|
||||||
allgemein_negativ: 'Allgemein',
|
|
||||||
positiv: 'Positiv',
|
|
||||||
sonstiges: 'Sonstiges',
|
|
||||||
};
|
|
||||||
|
|
||||||
const hinweisHtml = data.hinweis ? `
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
|
|
||||||
background:rgba(245,158,11,0.12);border:1px solid var(--c-warning,#f59e0b);
|
|
||||||
border-radius:var(--radius-md);padding:var(--space-3);
|
|
||||||
margin-bottom:var(--space-3);font-size:var(--text-xs);
|
|
||||||
color:var(--c-text);line-height:1.5">
|
|
||||||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-warning,#f59e0b);margin-top:1px">
|
|
||||||
<use href="/icons/phosphor.svg#warning-circle"></use>
|
|
||||||
</svg>
|
|
||||||
<span>${_esc(data.hinweis)}</span>
|
|
||||||
</div>
|
|
||||||
` : '';
|
|
||||||
|
|
||||||
analyseEl.innerHTML = `
|
|
||||||
<h4 style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3);color:var(--c-text)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chart-bar"></use></svg>
|
|
||||||
Verträglichkeits-Analyse
|
|
||||||
<span style="font-weight:400;color:var(--c-text-muted);font-size:var(--text-xs)">
|
|
||||||
(${data.eintraege_count} Mahlzeiten, ${data.reaktionen_count} Reaktionen)
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
${hinweisHtml}
|
|
||||||
<div style="display:grid;gap:var(--space-2)">
|
|
||||||
${data.futter.map(f => {
|
|
||||||
const cfg = STATUS_CFG[f.status] || STATUS_CFG.neu;
|
|
||||||
// Symptom-Kategorien des Futters als Chips
|
|
||||||
const katChips = Object.entries(f.kategorien || {})
|
|
||||||
.filter(([kat]) => kat !== 'positiv')
|
|
||||||
.map(([kat, cnt]) => {
|
|
||||||
const isHaut = kat === 'haut_negativ';
|
|
||||||
const isGastro = kat === 'gastro_negativ';
|
|
||||||
const chipColor = isHaut ? 'var(--c-warning,#f59e0b)' :
|
|
||||||
isGastro ? 'var(--c-danger,#ef4444)' :
|
|
||||||
'var(--c-text-muted)';
|
|
||||||
return `<span style="font-size:10px;font-weight:600;padding:2px 6px;
|
|
||||||
border-radius:999px;border:1px solid ${chipColor};
|
|
||||||
color:${chipColor};white-space:nowrap">
|
|
||||||
${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
|
|
||||||
</span>`;
|
|
||||||
}).join('');
|
|
||||||
return `
|
|
||||||
<div style="background:${cfg.bg};border:1px solid ${cfg.color};
|
|
||||||
border-radius:var(--radius-md);padding:var(--space-3);
|
|
||||||
display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)">
|
|
||||||
<div style="min-width:0;flex:1">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);
|
|
||||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
||||||
${_esc(f.name)}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
${_esc(TYP_LABELS[f.typ] || f.typ)} · ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
|
|
||||||
${f.status !== 'neu' ? `· <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
|
|
||||||
color:${cfg.color};white-space:nowrap">
|
|
||||||
${_esc(cfg.label)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadVerlauf(el, dog) {
|
|
||||||
const verlaufEl = el.querySelector('#vert-verlauf');
|
|
||||||
if (!verlaufEl) return;
|
|
||||||
|
|
||||||
let eintraege = [], reaktionen = [];
|
|
||||||
try {
|
|
||||||
[eintraege, reaktionen] = await Promise.all([
|
|
||||||
API.dogs.futterList(dog.id),
|
|
||||||
API.dogs.reaktionList(dog.id),
|
|
||||||
]);
|
|
||||||
} catch (_) { return; }
|
|
||||||
|
|
||||||
// Letzten 10 Futter + 5 Reaktionen, gemischt chronologisch
|
|
||||||
const items = [
|
|
||||||
...(eintraege || []).slice(0, 10).map(e => ({ ...e, _art: 'futter' })),
|
|
||||||
...(reaktionen || []).slice(0, 5).map(r => ({ ...r, _art: 'reaktion' })),
|
|
||||||
].sort((a, b) => {
|
|
||||||
const ta = `${a.datum}T${a.uhrzeit}`;
|
|
||||||
const tb = `${b.datum}T${b.uhrzeit}`;
|
|
||||||
return tb.localeCompare(ta);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
verlaufEl.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REAK_LABELS = {
|
|
||||||
// Positiv
|
|
||||||
verdauung_gut: 'Gute Verdauung',
|
|
||||||
energie_hoch: 'Viel Energie',
|
|
||||||
fell_glaenzend: 'Glänzendes Fell',
|
|
||||||
// Gastro
|
|
||||||
erbrechen: 'Erbrechen',
|
|
||||||
durchfall: 'Durchfall',
|
|
||||||
blaehungen: 'Blähungen',
|
|
||||||
weicher_stuhl: 'Weicher Stuhl',
|
|
||||||
appetitlosigkeit: 'Appetitlosigkeit',
|
|
||||||
// Haut & Fell
|
|
||||||
juckreiz: 'Juckreiz / Kratzen',
|
|
||||||
haarausfall: 'Haarausfall',
|
|
||||||
stumpfes_fell: 'Stumpfes Fell',
|
|
||||||
schuppenbildung: 'Schuppenbildung',
|
|
||||||
roetungen: 'Hautrötungen / Entzündung',
|
|
||||||
pfotenlecken: 'Pfoten lecken (chronisch)',
|
|
||||||
ohrentzuendung: 'Ohrentzündung',
|
|
||||||
fettiges_fell: 'Fettiges Fell / Seborrhö',
|
|
||||||
// Allgemein
|
|
||||||
schlappheit: 'Schlappheit / Apathie',
|
|
||||||
nervositaet: 'Nervosität / Unruhe',
|
|
||||||
viel_trinken: 'Ungewöhnlich viel trinken',
|
|
||||||
sonstiges: 'Sonstiges',
|
|
||||||
};
|
|
||||||
const NEGATIV_TYPEN = new Set([
|
|
||||||
'erbrechen','durchfall','blaehungen','weicher_stuhl','appetitlosigkeit',
|
|
||||||
'juckreiz','haarausfall','stumpfes_fell','schuppenbildung','roetungen',
|
|
||||||
'pfotenlecken','ohrentzuendung','fettiges_fell',
|
|
||||||
'schlappheit','nervositaet','viel_trinken',
|
|
||||||
]);
|
|
||||||
const POSITIV_TYPEN = new Set(['verdauung_gut','energie_hoch','fell_glaenzend']);
|
|
||||||
|
|
||||||
verlaufEl.innerHTML = `
|
|
||||||
<h4 style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-2);color:var(--c-text)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock-counter-clockwise"></use></svg>
|
|
||||||
Verlauf
|
|
||||||
</h4>
|
|
||||||
<div style="display:grid;gap:var(--space-2)">
|
|
||||||
${items.map(item => {
|
|
||||||
if (item._art === 'futter') {
|
|
||||||
return `
|
|
||||||
<div data-futter-id="${item.id}"
|
|
||||||
style="background:var(--c-surface);border:1px solid var(--c-border);
|
|
||||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
|
||||||
display:flex;align-items:center;gap:var(--space-2)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
|
|
||||||
<use href="/icons/phosphor.svg#bowl-food"></use>
|
|
||||||
</svg>
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.futter_name)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
${_esc(item.datum)} ${_esc(item.uhrzeit)}
|
|
||||||
${item.menge_g ? ` · ${item.menge_g} g` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon vert-del-futter" data-id="${item.id}"
|
|
||||||
style="flex-shrink:0;background:none;border:none;cursor:pointer;padding:4px;
|
|
||||||
color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
const isNeg = NEGATIV_TYPEN.has(item.reaktion_typ);
|
|
||||||
const isPos = POSITIV_TYPEN.has(item.reaktion_typ);
|
|
||||||
const col = isNeg ? 'var(--c-danger,#ef4444)' : isPos ? 'var(--c-success,#22c55e)' : 'var(--c-text-muted)';
|
|
||||||
return `
|
|
||||||
<div data-reaktion-id="${item.id}"
|
|
||||||
style="background:var(--c-surface);border:1px solid var(--c-border);
|
|
||||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
|
||||||
display:flex;align-items:center;gap:var(--space-2)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}">
|
|
||||||
<use href="/icons/phosphor.svg#heartbeat"></use>
|
|
||||||
</svg>
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm);color:${col}">
|
|
||||||
${_esc(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
|
|
||||||
<span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
${_esc(item.datum)} ${_esc(item.uhrzeit)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon vert-del-reaktion" data-id="${item.id}"
|
|
||||||
style="flex-shrink:0;background:none;border:none;cursor:pointer;padding:4px;
|
|
||||||
color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Löschen-Buttons
|
|
||||||
verlaufEl.querySelectorAll('.vert-del-futter').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
if (!window.confirm('Eintrag löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.dogs.futterDelete(dog.id, parseInt(btn.dataset.id));
|
|
||||||
UI.toast.success('Eintrag gelöscht.');
|
|
||||||
const tabEl = _container.querySelector('#ern-tab-content');
|
|
||||||
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message || 'Fehler.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
verlaufEl.querySelectorAll('.vert-del-reaktion').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
if (!window.confirm('Reaktion löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.dogs.reaktionDelete(dog.id, parseInt(btn.dataset.id));
|
|
||||||
UI.toast.success('Reaktion gelöscht.');
|
|
||||||
const tabEl = _container.querySelector('#ern-tab-content');
|
|
||||||
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message || 'Fehler.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// PUBLIC API
|
// PUBLIC API
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!_appState.activeDog) return;
|
if (!_appState.activeDog) return;
|
||||||
|
if (_appState.dogs.length > 1) {
|
||||||
|
_renderDogPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_data = {};
|
_data = {};
|
||||||
await _renderHealth();
|
await _renderHealth();
|
||||||
}
|
}
|
||||||
|
|
@ -77,8 +81,53 @@ window.Page_health = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_appState.dogs.length > 1) {
|
||||||
|
_renderDogPicker();
|
||||||
|
} else {
|
||||||
await _renderHealth();
|
await _renderHealth();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// HUNDE-PICKER
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderDogPicker() {
|
||||||
|
const activeDogId = _appState.activeDog?.id;
|
||||||
|
|
||||||
|
const cards = _appState.dogs.map(dog => {
|
||||||
|
const isActive = dog.id === activeDogId;
|
||||||
|
const av = dog.foto_url
|
||||||
|
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
|
||||||
|
: `<span>${UI.icon('dog')}</span>`;
|
||||||
|
return `
|
||||||
|
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
||||||
|
data-dog-id="${dog.id}">
|
||||||
|
<div class="diary-picker-av">${av}</div>
|
||||||
|
<div class="diary-picker-name">${_esc(dog.name)}</div>
|
||||||
|
${dog.rasse ? `<div class="diary-picker-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div class="diary-picker-wrap">
|
||||||
|
<p class="diary-picker-hint">Wessen Gesundheitsakte?</p>
|
||||||
|
<div class="diary-picker-grid">${cards}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_container.querySelectorAll('.diary-picker-card').forEach(el => {
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(el.dataset.dogId);
|
||||||
|
if (id === _appState.activeDog?.id) {
|
||||||
|
// Bereits aktiver Hund → direkt Health laden
|
||||||
|
_data = {};
|
||||||
|
await _renderHealth();
|
||||||
|
} else {
|
||||||
|
App.setActiveDog(id);
|
||||||
|
// onDogChange() → _renderHealth() via _notifyDogChange()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// HEALTH-ANSICHT — Tabs mit Einträgen
|
// HEALTH-ANSICHT — Tabs mit Einträgen
|
||||||
|
|
@ -98,7 +147,6 @@ window.Page_health = (() => {
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
${UI.dogChip(_appState)}
|
|
||||||
<div class="by-toolbar health-header">
|
<div class="by-toolbar health-header">
|
||||||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||||
${UI.icon('star')} KI-Zusammenfassung
|
${UI.icon('star')} KI-Zusammenfassung
|
||||||
|
|
@ -116,7 +164,6 @@ window.Page_health = (() => {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
_renderTabBar();
|
_renderTabBar();
|
||||||
UI.bindDogChip(_container, _appState);
|
|
||||||
_container.querySelector('#health-ki-btn')
|
_container.querySelector('#health-ki-btn')
|
||||||
.addEventListener('click', _showKiSummary);
|
.addEventListener('click', _showKiSummary);
|
||||||
_container.querySelector('#health-ki-tierarzt-btn')
|
_container.querySelector('#health-ki-tierarzt-btn')
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ window.Page_map = (() => {
|
||||||
let _map = null;
|
let _map = null;
|
||||||
let _leafletLoaded = false;
|
let _leafletLoaded = false;
|
||||||
let _userPos = null;
|
let _userPos = null;
|
||||||
let _weatherLoaded = false;
|
|
||||||
let _placingMarker = false;
|
let _placingMarker = false;
|
||||||
let _tempMarker = null;
|
let _tempMarker = null;
|
||||||
|
|
||||||
|
|
@ -148,7 +147,6 @@ window.Page_map = (() => {
|
||||||
_userPos = pos;
|
_userPos = pos;
|
||||||
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
|
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
|
||||||
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
|
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
|
||||||
_weatherLoaded = true;
|
|
||||||
_loadWeather(pos.lat, pos.lon);
|
_loadWeather(pos.lat, pos.lon);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
const btn = document.getElementById('map-locate-btn');
|
const btn = document.getElementById('map-locate-btn');
|
||||||
|
|
@ -203,8 +201,6 @@ window.Page_map = (() => {
|
||||||
|
|
||||||
<div class="map-fabs">
|
<div class="map-fabs">
|
||||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||||
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
|
||||||
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
|
||||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -287,186 +283,6 @@ window.Page_map = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||||
document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar);
|
|
||||||
document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
let _radarLayer = null;
|
|
||||||
let _radarActive = false;
|
|
||||||
let _radarTimer = null;
|
|
||||||
let _tempLayer = null;
|
|
||||||
let _tempActive = false;
|
|
||||||
let _tempMarkers = [];
|
|
||||||
let _tempDebounce = null;
|
|
||||||
|
|
||||||
async function _toggleRadar() {
|
|
||||||
if (!App.hasPro(_appState?.user)) {
|
|
||||||
UI.toast.info('Regenradar ist ein Pro-Feature.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('map-radar-btn');
|
|
||||||
if (_radarActive) {
|
|
||||||
_radarActive = false;
|
|
||||||
if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; }
|
|
||||||
clearInterval(_radarTimer);
|
|
||||||
btn?.classList.remove('active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_radarActive = true;
|
|
||||||
btn?.classList.add('active');
|
|
||||||
if (_map && _map.getZoom() > 7) _map.setZoom(7);
|
|
||||||
await _loadRadar();
|
|
||||||
_radarTimer = setInterval(_loadRadar, 5 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _toggleTemp() {
|
|
||||||
if (!App.hasPro(_appState?.user)) {
|
|
||||||
UI.toast.info('Temperatur-Layer ist ein Pro-Feature.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('map-temp-btn');
|
|
||||||
if (_tempActive) {
|
|
||||||
_tempActive = false;
|
|
||||||
if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; }
|
|
||||||
_tempMarkers.forEach(m => _map.removeLayer(m));
|
|
||||||
_tempMarkers = [];
|
|
||||||
clearTimeout(_tempDebounce);
|
|
||||||
_map.off('moveend zoomend', _debounceTempLabels);
|
|
||||||
document.getElementById('map-temp-legend')?.remove();
|
|
||||||
btn?.classList.remove('active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_tempActive = true;
|
|
||||||
btn?.classList.add('active');
|
|
||||||
try {
|
|
||||||
const cfg = await API.get('/weather/layer-tiles?layer=temp_new');
|
|
||||||
_tempLayer = window.L.tileLayer(cfg.url, {
|
|
||||||
opacity: 1.0,
|
|
||||||
tileSize: 256,
|
|
||||||
zIndex: 290,
|
|
||||||
maxNativeZoom: cfg.maxNativeZoom ?? 18,
|
|
||||||
maxZoom: 18,
|
|
||||||
attribution: 'Temp © <a href="https://openweathermap.org">OpenWeatherMap</a>',
|
|
||||||
}).addTo(_map);
|
|
||||||
_showTempLegend();
|
|
||||||
_map.on('moveend zoomend', _debounceTempLabels);
|
|
||||||
await _loadTempLabels();
|
|
||||||
} catch {
|
|
||||||
_tempActive = false;
|
|
||||||
btn?.classList.remove('active');
|
|
||||||
UI.toast.error('Temperatur-Layer nicht verfügbar.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _debounceTempLabels() {
|
|
||||||
clearTimeout(_tempDebounce);
|
|
||||||
_tempDebounce = setTimeout(_loadTempLabels, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _tempColor(t) {
|
|
||||||
if (t <= -10) return '#0033cc';
|
|
||||||
if (t <= 0) return '#0099ff';
|
|
||||||
if (t <= 10) return '#00cc88';
|
|
||||||
if (t <= 15) return '#88cc00';
|
|
||||||
if (t <= 20) return '#ffcc00';
|
|
||||||
if (t <= 25) return '#ff8800';
|
|
||||||
if (t <= 30) return '#ff3300';
|
|
||||||
return '#990000';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadTempLabels() {
|
|
||||||
if (!_tempActive || !_map) return;
|
|
||||||
const bounds = _map.getBounds();
|
|
||||||
const n = bounds.getNorth(), s = bounds.getSouth();
|
|
||||||
const e = bounds.getEast(), w = bounds.getWest();
|
|
||||||
|
|
||||||
// 3×3 Raster
|
|
||||||
const rows = 3, cols = 3;
|
|
||||||
const points = [];
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
for (let c = 0; c < cols; c++) {
|
|
||||||
const lat = s + (n - s) * (r + 0.5) / rows;
|
|
||||||
const lon = w + (e - w) * (c + 0.5) / cols;
|
|
||||||
points.push([lat, lon]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(points.map(([lat, lon]) =>
|
|
||||||
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
|
|
||||||
.catch(() => null)
|
|
||||||
));
|
|
||||||
|
|
||||||
// Alte Marker entfernen
|
|
||||||
_tempMarkers.forEach(m => _map.removeLayer(m));
|
|
||||||
_tempMarkers = [];
|
|
||||||
|
|
||||||
results.filter(Boolean).forEach(({ lat, lon, t }) => {
|
|
||||||
if (t == null) return;
|
|
||||||
const temp = Math.round(t);
|
|
||||||
const color = _tempColor(temp);
|
|
||||||
const icon = window.L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="background:${color};color:#fff;font-size:12px;font-weight:800;
|
|
||||||
padding:2px 6px;border-radius:10px;white-space:nowrap;
|
|
||||||
box-shadow:0 1px 4px rgba(0,0,0,0.5);text-shadow:0 1px 2px rgba(0,0,0,0.4)">${temp}°</div>`,
|
|
||||||
iconSize: null,
|
|
||||||
iconAnchor: [20, 10],
|
|
||||||
});
|
|
||||||
const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false });
|
|
||||||
m.addTo(_map);
|
|
||||||
_tempMarkers.push(m);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showTempLegend() {
|
|
||||||
const existing = document.getElementById('map-temp-legend');
|
|
||||||
if (existing) return;
|
|
||||||
const steps = [
|
|
||||||
{ c: '#0000cc', v: '−20°' }, { c: '#0055ff', v: '−10°' },
|
|
||||||
{ c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' },
|
|
||||||
{ c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' },
|
|
||||||
{ c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' },
|
|
||||||
{ c: '#990000', v: '35°' },
|
|
||||||
];
|
|
||||||
const gradient = steps.map(s => s.c).join(',');
|
|
||||||
const labels = steps.map(s =>
|
|
||||||
`<span style="flex:1;text-align:center;font-size:9px;color:#fff;text-shadow:0 0 3px #000">${s.v}</span>`
|
|
||||||
).join('');
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.id = 'map-temp-legend';
|
|
||||||
el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%);
|
|
||||||
z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px;
|
|
||||||
min-width:220px;pointer-events:none`;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="height:10px;border-radius:3px;background:linear-gradient(to right,${gradient});margin-bottom:2px"></div>
|
|
||||||
<div style="display:flex">${labels}</div>`;
|
|
||||||
document.getElementById('central-map')?.appendChild(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadRadar() {
|
|
||||||
if (!_radarActive || !_map) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
|
|
||||||
const data = await resp.json();
|
|
||||||
const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])];
|
|
||||||
if (!frames.length) return;
|
|
||||||
const latest = frames[frames.length - 1].path;
|
|
||||||
const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`;
|
|
||||||
if (_radarLayer) _map.removeLayer(_radarLayer);
|
|
||||||
_radarLayer = window.L.tileLayer(url, {
|
|
||||||
opacity: 0.7,
|
|
||||||
tileSize: 256,
|
|
||||||
zIndex: 300,
|
|
||||||
maxNativeZoom: 7,
|
|
||||||
maxZoom: 18,
|
|
||||||
attribution: 'Radar © <a href="https://rainviewer.com">RainViewer</a>',
|
|
||||||
}).addTo(_map);
|
|
||||||
} catch { /* still */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -557,7 +373,6 @@ window.Page_map = (() => {
|
||||||
pos => {
|
pos => {
|
||||||
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
|
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
|
||||||
_userPos = { lat, lon };
|
_userPos = { lat, lon };
|
||||||
if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
|
|
||||||
if (_locationMarker) {
|
if (_locationMarker) {
|
||||||
_locationMarker.setLatLng([lat, lon]);
|
_locationMarker.setLatLng([lat, lon]);
|
||||||
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
|
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
|
||||||
|
|
@ -1688,34 +1503,11 @@ window.Page_map = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showRecSaveModal(track, distKm, dauMin) {
|
function _showRecSaveModal(track, distKm, dauMin) {
|
||||||
const dogs = _appState?.dogs || [];
|
|
||||||
const activeDogId = _appState?.activeDog?.id;
|
|
||||||
const dogPickerHtml = dogs.length > 1 ? `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Welche Hunde waren dabei?</label>
|
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
|
||||||
${dogs.map(d => {
|
|
||||||
const checked = d.id === activeDogId;
|
|
||||||
const av = d.foto_url
|
|
||||||
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
|
||||||
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
|
||||||
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
|
|
||||||
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
|
|
||||||
font-size:var(--text-xs);font-weight:600;user-select:none">
|
|
||||||
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
|
|
||||||
style="display:none" class="rec-dog-cb">
|
|
||||||
${av}<span>${UI.escape(d.name)}</span>
|
|
||||||
</label>`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : '';
|
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||||
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
||||||
</p>
|
</p>
|
||||||
<form id="rec-save-form" autocomplete="off">
|
<form id="rec-save-form" autocomplete="off">
|
||||||
${dogPickerHtml}
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Name der Route *</label>
|
<label class="form-label">Name der Route *</label>
|
||||||
<input class="form-control" type="text" name="name"
|
<input class="form-control" type="text" name="name"
|
||||||
|
|
@ -1795,23 +1587,10 @@ window.Page_map = (() => {
|
||||||
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
|
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hund-Checkbox Toggle-Styling
|
|
||||||
document.querySelectorAll('.rec-dog-cb').forEach(cb => {
|
|
||||||
const label = cb.closest('label');
|
|
||||||
const update = () => {
|
|
||||||
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
|
|
||||||
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
|
|
||||||
label.style.color = cb.checked ? 'var(--c-primary)' : '';
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
cb.addEventListener('change', update);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
|
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
|
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
|
||||||
const fd = UI.formData(e.target);
|
const fd = UI.formData(e.target);
|
||||||
const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value));
|
|
||||||
await UI.asyncButton(btn, async () => {
|
await UI.asyncButton(btn, async () => {
|
||||||
const saved = await API.routes.create({
|
const saved = await API.routes.create({
|
||||||
name: fd.name?.trim(),
|
name: fd.name?.trim(),
|
||||||
|
|
@ -1825,7 +1604,6 @@ window.Page_map = (() => {
|
||||||
leine_empfohlen: 'leine_empfohlen' in fd,
|
leine_empfohlen: 'leine_empfohlen' in fd,
|
||||||
is_public: 'is_public' in fd,
|
is_public: 'is_public' in fd,
|
||||||
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
||||||
dog_ids: dogIds.length ? dogIds : null,
|
|
||||||
});
|
});
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
|
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
|
||||||
|
|
@ -1850,18 +1628,13 @@ window.Page_map = (() => {
|
||||||
const w = await API.weather.get(lat, lon);
|
const w = await API.weather.get(lat, lon);
|
||||||
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '–';
|
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '–';
|
||||||
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
|
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
|
||||||
const regen = w.precip_prob != null
|
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : '';
|
||||||
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
|
|
||||||
: '';
|
|
||||||
const warning = w.rain_warning_time
|
|
||||||
? ` · <span style="color:#f59e0b;font-weight:700">⚠ ab ${w.rain_warning_time}</span>`
|
|
||||||
: '';
|
|
||||||
let zecken = '';
|
let zecken = '';
|
||||||
if (w.zecken_warnung) {
|
if (w.zecken_warnung) {
|
||||||
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
|
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
|
||||||
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
|
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
|
||||||
}
|
}
|
||||||
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`;
|
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`;
|
||||||
info.classList.remove('map-weather-chip--hidden');
|
info.classList.remove('map-weather-chip--hidden');
|
||||||
sep.classList.remove('map-weather-chip--hidden');
|
sep.classList.remove('map-weather-chip--hidden');
|
||||||
} catch { /* still */ }
|
} catch { /* still */ }
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ window.Page_notifications = (() => {
|
||||||
? App.callModule('poison', 'openDetail', { id: d.id })
|
? App.callModule('poison', 'openDetail', { id: d.id })
|
||||||
: App.navigate('poison');
|
: App.navigate('poison');
|
||||||
break;
|
break;
|
||||||
case 'friend_request': _openFriendRequestModal(); break;
|
case 'friend_request': App.navigate('friends'); break;
|
||||||
case 'health_reminder':App.navigate('health'); break;
|
case 'health_reminder':App.navigate('health'); break;
|
||||||
case 'milestone': App.navigate('diary'); break;
|
case 'milestone': App.navigate('diary'); break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -379,55 +379,5 @@ window.Page_notifications = (() => {
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _openFriendRequestModal() {
|
|
||||||
let requests;
|
|
||||||
try { requests = await API.friends.pending(); } catch { requests = []; }
|
|
||||||
|
|
||||||
if (!requests.length) {
|
|
||||||
UI.toast.info('Keine offenen Freundschaftsanfragen.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = requests.map(r => `
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) 0;
|
|
||||||
border-bottom:1px solid var(--c-border)">
|
|
||||||
<div style="width:36px;height:36px;border-radius:50%;overflow:hidden;flex-shrink:0;
|
|
||||||
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center">
|
|
||||||
${r.avatar_url
|
|
||||||
? `<img src="${UI.escape(r.avatar_url)}" style="width:100%;height:100%;object-fit:cover">`
|
|
||||||
: `<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#user"></use></svg>`}
|
|
||||||
</div>
|
|
||||||
<div style="flex:1;font-weight:600">${UI.escape(r.requester_name)}</div>
|
|
||||||
<button class="btn btn-primary" style="padding:6px 14px;font-size:var(--text-sm)"
|
|
||||||
data-accept="${r.id}">Annehmen</button>
|
|
||||||
<button class="btn btn-secondary" style="padding:6px 10px;font-size:var(--text-sm)"
|
|
||||||
data-decline="${r.id}">✕</button>
|
|
||||||
</div>`).join('');
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Freundschaftsanfragen',
|
|
||||||
body: `<div>${items}</div>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-accept]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.friends.accept(parseInt(btn.dataset.accept));
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Freundschaft angenommen!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.querySelectorAll('[data-decline]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.friends.decline(parseInt(btn.dataset.decline));
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.info('Anfrage abgelehnt.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init };
|
return { init };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -320,17 +320,7 @@ window.Page_routes = (() => {
|
||||||
if (actRow) actRow.style.display = 'none';
|
if (actRow) actRow.style.display = 'none';
|
||||||
const filterPanel = document.getElementById('rk-filter-panel');
|
const filterPanel = document.getElementById('rk-filter-panel');
|
||||||
if (filterPanel) filterPanel.style.display = 'none';
|
if (filterPanel) filterPanel.style.display = 'none';
|
||||||
if (!App.hasPro(_appState?.user)) {
|
|
||||||
document.getElementById('rk-list')?.replaceChildren();
|
|
||||||
const gate = document.createElement('div');
|
|
||||||
gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)';
|
|
||||||
gate.innerHTML = `<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
|
|
||||||
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
|
|
||||||
<div style="font-size:var(--text-sm)">Routenvorschläge sind ein Pro-Feature.</div>`;
|
|
||||||
document.getElementById('rk-list')?.appendChild(gate);
|
|
||||||
} else {
|
|
||||||
_renderSuggestTab();
|
_renderSuggestTab();
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (searchRow) searchRow.style.display = '';
|
if (searchRow) searchRow.style.display = '';
|
||||||
if (actRow) actRow.style.display = '';
|
if (actRow) actRow.style.display = '';
|
||||||
|
|
@ -2156,7 +2146,6 @@ window.Page_routes = (() => {
|
||||||
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
|
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
|
||||||
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
|
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
|
||||||
${_actionBtn('rd-reverse', 'path', 'Umkehren')}
|
${_actionBtn('rd-reverse', 'path', 'Umkehren')}
|
||||||
${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''}
|
|
||||||
${_actionBtn('rd-del', 'trash', 'Löschen', true)}
|
${_actionBtn('rd-del', 'trash', 'Löschen', true)}
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
|
|
@ -2245,9 +2234,6 @@ window.Page_routes = (() => {
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hunde bearbeiten
|
|
||||||
document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route));
|
|
||||||
|
|
||||||
// Löschen
|
// Löschen
|
||||||
document.getElementById('rd-del')?.addEventListener('click', async () => {
|
document.getElementById('rd-del')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
|
|
@ -2308,70 +2294,6 @@ window.Page_routes = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Hunde einer Route bearbeiten
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _openEditDogsModal(route) {
|
|
||||||
const dogs = _appState?.dogs || [];
|
|
||||||
if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; }
|
|
||||||
|
|
||||||
const currentIds = new Set(route.dog_ids || []);
|
|
||||||
|
|
||||||
const dogRows = dogs.map(d => {
|
|
||||||
const checked = currentIds.has(d.id);
|
|
||||||
const av = d.foto_url
|
|
||||||
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
|
||||||
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
|
||||||
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
|
|
||||||
border:1.5px solid ${checked ? 'var(--c-primary)' : 'var(--c-border)'};
|
|
||||||
border-radius:100px;cursor:pointer;
|
|
||||||
background:${checked ? 'var(--c-primary-subtle)' : ''};
|
|
||||||
color:${checked ? 'var(--c-primary)' : ''};
|
|
||||||
font-size:var(--text-xs);font-weight:600;user-select:none">
|
|
||||||
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
|
|
||||||
style="display:none" class="rd-dog-cb">
|
|
||||||
${av}<span>${UI.escape(d.name)}</span>
|
|
||||||
</label>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
||||||
Welche Hunde waren bei dieser Route dabei?
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)" id="rd-dogs-picker">
|
|
||||||
${dogRows}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const footer = `
|
|
||||||
<button type="button" class="btn btn-secondary flex-1" id="rd-dogs-cancel">Abbrechen</button>
|
|
||||||
<button type="button" class="btn btn-primary flex-1" id="rd-dogs-save">${UI.icon('floppy-disk')} Speichern</button>
|
|
||||||
`;
|
|
||||||
UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer });
|
|
||||||
|
|
||||||
// Checkbox-Pill Styling
|
|
||||||
document.querySelectorAll('.rd-dog-cb').forEach(cb => {
|
|
||||||
const label = cb.closest('label');
|
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
|
|
||||||
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
|
|
||||||
label.style.color = cb.checked ? 'var(--c-primary)' : '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close);
|
|
||||||
|
|
||||||
document.getElementById('rd-dogs-save')?.addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('rd-dogs-save');
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value));
|
|
||||||
await API.routes.updateDogs(route.id, dogIds);
|
|
||||||
route.dog_ids = dogIds;
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Hunde aktualisiert.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Richtungspfeile gleichmäßig entlang des Tracks platzieren
|
// Richtungspfeile gleichmäßig entlang des Tracks platzieren
|
||||||
function _addRouteArrows(map, track, color = '#fff') {
|
function _addRouteArrows(map, track, color = '#fff') {
|
||||||
if (track.length < 2) return;
|
if (track.length < 2) return;
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,6 @@ window.Page_settings = (() => {
|
||||||
<span>Hunde-Profile</span>
|
<span>Hunde-Profile</span>
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-erinnerungen-wrap"></div>
|
|
||||||
<div class="sidebar-item" id="settings-push-btn"
|
<div class="sidebar-item" id="settings-push-btn"
|
||||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
|
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
|
||||||
|
|
@ -270,12 +269,6 @@ window.Page_settings = (() => {
|
||||||
<span>Hilfe & FAQ</span>
|
<span>Hilfe & FAQ</span>
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-item" id="settings-feedback-btn"
|
|
||||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-dots"></use></svg>
|
|
||||||
<span>Feedback geben</span>
|
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
|
||||||
</div>
|
|
||||||
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
|
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
|
||||||
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
|
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
|
||||||
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
|
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
|
||||||
|
|
@ -445,38 +438,6 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Verstorbene Hunde in Erinnerungen-Sektion laden
|
|
||||||
API.get('/dogs/verstorben').then(dogs => {
|
|
||||||
const el = document.getElementById('settings-erinnerungen-wrap');
|
|
||||||
if (!el || !dogs.length) return;
|
|
||||||
el.innerHTML = dogs.map(d => {
|
|
||||||
const av = d.foto_url
|
|
||||||
? `<img src="${_esc(d.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
|
||||||
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
||||||
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
|
|
||||||
</div>`;
|
|
||||||
const jahr = d.verstorben_am ? d.verstorben_am.slice(0, 4) : '';
|
|
||||||
return `
|
|
||||||
<div class="sidebar-item settings-erinnerung-btn" data-dog-id="${d.id}" data-dog-name="${_esc(d.name)}"
|
|
||||||
style="padding:var(--space-3) var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
|
|
||||||
${av}
|
|
||||||
<div style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0">
|
|
||||||
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(d.name)}</span>
|
|
||||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
|
|
||||||
Erinnerungen${jahr ? ' · ' + jahr : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
el.querySelectorAll('.settings-erinnerung-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => _openGedenkseite(
|
|
||||||
parseInt(btn.dataset.dogId), btn.dataset.dogName
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
// Achievements laden (Streak + Stats + Badges)
|
// Achievements laden (Streak + Stats + Badges)
|
||||||
API.get('/achievements/me').then(a => {
|
API.get('/achievements/me').then(a => {
|
||||||
const statsEl = document.getElementById('settings-stats-body');
|
const statsEl = document.getElementById('settings-stats-body');
|
||||||
|
|
@ -829,52 +790,6 @@ window.Page_settings = (() => {
|
||||||
App.navigate('hilfe');
|
App.navigate('hilfe');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
|
|
||||||
const sel = (id) => document.getElementById(id);
|
|
||||||
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Feedback geben',
|
|
||||||
body: `
|
|
||||||
<form id="feedback-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
|
||||||
<div>
|
|
||||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Kategorie</label>
|
|
||||||
<select id="feedback-kat" name="kategorie" style="${inputStyle}">
|
|
||||||
<option value="idee">💡 Idee / Wunsch</option>
|
|
||||||
<option value="bug">🐛 Bug / Fehler</option>
|
|
||||||
<option value="lob">🎉 Lob</option>
|
|
||||||
<option value="sonstiges">💬 Sonstiges</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Deine Nachricht</label>
|
|
||||||
<textarea id="feedback-text" name="text" rows="5" maxlength="2000"
|
|
||||||
placeholder="Was möchtest du uns mitteilen?"
|
|
||||||
style="${inputStyle};resize:vertical"></textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
footer: `
|
|
||||||
<div class="w3-btn-stack">
|
|
||||||
<button type="submit" form="feedback-form" id="feedback-submit-btn" class="btn btn-primary" style="width:100%">Absenden</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
sel('feedback-form')?.addEventListener('submit', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = sel('feedback-submit-btn');
|
|
||||||
const kat = sel('feedback-kat')?.value;
|
|
||||||
const text = sel('feedback-text')?.value?.trim();
|
|
||||||
if (!text) { UI.toast.error('Bitte schreib etwas.'); return; }
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.post('/feedback', { kategorie: kat, text });
|
|
||||||
UI.modal.close?.();
|
|
||||||
UI.toast.success('Vielen Dank für dein Feedback!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title : 'Abmelden?',
|
title : 'Abmelden?',
|
||||||
|
|
@ -1489,80 +1404,6 @@ window.Page_settings = (() => {
|
||||||
} catch { el.innerHTML = ''; }
|
} catch { el.innerHTML = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// GEDENKSEITE — für verstorbene Hunde
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _openGedenkseite(dogId, dogName) {
|
|
||||||
UI.modal.open({ title: `Erinnerungen an ${_esc(dogName)}`, body: `
|
|
||||||
<div style="text-align:center;padding:var(--space-4)">
|
|
||||||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#spinner"></use>
|
|
||||||
</svg>
|
|
||||||
</div>` });
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
|
|
||||||
catch { UI.modal.close(); return; }
|
|
||||||
|
|
||||||
const d = data;
|
|
||||||
const av = d.dog.foto_url
|
|
||||||
? `<img src="${_esc(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
|
|
||||||
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
|
|
||||||
|
|
||||||
const photoGrid = d.photos?.length ? `
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
|
|
||||||
${d.photos.map(url => `<img src="${_esc(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
|
|
||||||
</div>` : '';
|
|
||||||
|
|
||||||
const statsHtml = `
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
|
|
||||||
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
|
||||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const passed = d.dog.verstorben_am;
|
|
||||||
const passedStr = passed
|
|
||||||
? new Date(passed).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: `Erinnerungen an ${_esc(d.dog.name)}`,
|
|
||||||
body: `
|
|
||||||
<div style="text-align:center;margin-bottom:var(--space-4)">
|
|
||||||
${av}
|
|
||||||
<div style="margin-top:var(--space-3);font-size:var(--text-lg);font-weight:700">${_esc(d.dog.name)}</div>
|
|
||||||
${passedStr ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:4px">
|
|
||||||
<svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
|
|
||||||
${passedStr}
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
${statsHtml}
|
|
||||||
${photoGrid}
|
|
||||||
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
|
|
||||||
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
|
|
||||||
Der Schmerz über den Verlust eines Hundes ist real und tief. Lass dich trauern — die Erinnerungen bleiben immer bei dir.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
${d.ki_abschied ? `<div style="font-style:italic;font-size:var(--text-sm);color:var(--c-text-secondary);
|
|
||||||
line-height:1.7;padding:var(--space-3);background:var(--c-surface);
|
|
||||||
border-radius:var(--radius-md);border:1px solid var(--c-border)">
|
|
||||||
"${_esc(d.ki_abschied)}"
|
|
||||||
</div>` : ''}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// NICHT EINGELOGGT — Login / Registrierung
|
// NICHT EINGELOGGT — Login / Registrierung
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,7 @@ window.Page_trainingsplaene = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _lsKey(planId, goalIdx) {
|
function _lsKey(planId, goalIdx) {
|
||||||
const dogId = _dogId() || 'x';
|
return `tp_${planId}_${goalIdx}`;
|
||||||
return `tp_d${dogId}_${planId}_${goalIdx}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveGoal(key, checked) {
|
function _saveGoal(key, checked) {
|
||||||
|
|
@ -538,8 +537,6 @@ window.Page_trainingsplaene = (() => {
|
||||||
// BIND EVENTS
|
// BIND EVENTS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _bindEvents() {
|
function _bindEvents() {
|
||||||
UI.bindDogChip(_container, _appState);
|
|
||||||
|
|
||||||
// Notiz-Button
|
// Notiz-Button
|
||||||
const dogId = _dogId();
|
const dogId = _dogId();
|
||||||
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
|
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
|
||||||
|
|
@ -615,9 +612,8 @@ window.Page_trainingsplaene = (() => {
|
||||||
: `Erwachsener Hund – ${_activeAdultTab}`;
|
: `Erwachsener Hund – ${_activeAdultTab}`;
|
||||||
|
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="padding:var(--space-4) var(--space-4) var(--space-8)">
|
<div style="padding-bottom:var(--space-8)">
|
||||||
${UI.dogChip(_appState)}
|
<div style="display:flex;align-items:center;justify-content:space-between;margin:var(--space-4) 0 var(--space-4)">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
|
|
||||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
|
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
|
||||||
${_icon('clipboard-text')} Trainingspläne
|
${_icon('clipboard-text')} Trainingspläne
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -866,9 +862,7 @@ window.Page_trainingsplaene = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {}
|
function refresh() {}
|
||||||
function onDogChange() {
|
function onDogChange() {}
|
||||||
_render();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
// In-memory cache (loaded from API on init)
|
// In-memory cache (loaded from API on init)
|
||||||
let _progressCache = {}; // key → statusId
|
let _progressCache = {}; // key → statusId
|
||||||
let _progressLoaded = false;
|
|
||||||
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
|
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
|
||||||
|
|
||||||
function _progressKey(tab, name) {
|
function _progressKey(tab, name) {
|
||||||
|
|
@ -84,13 +83,17 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
function _getStatus(tab, name) {
|
function _getStatus(tab, name) {
|
||||||
const k = _progressKey(tab, name);
|
const k = _progressKey(tab, name);
|
||||||
return _progressCache[k] ?? null;
|
// Fallback to localStorage while API loads
|
||||||
|
return _progressCache[k] !== undefined
|
||||||
|
? _progressCache[k]
|
||||||
|
: localStorage.getItem(_statusKey(tab, name)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setStatus(tab, name, statusId) {
|
function _setStatus(tab, name, statusId) {
|
||||||
const k = _progressKey(tab, name);
|
const k = _progressKey(tab, name);
|
||||||
_progressCache[k] = statusId;
|
_progressCache[k] = statusId;
|
||||||
API.training.setProgress(k, statusId, _dogId()).catch(() => {});
|
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
|
||||||
|
API.training.setProgress(k, statusId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _nextStatus(currentId) {
|
function _nextStatus(currentId) {
|
||||||
|
|
@ -501,19 +504,28 @@ window.Page_uebungen = (() => {
|
||||||
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
|
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress vom Server laden (hund-spezifisch)
|
// Progress vom Server laden
|
||||||
const _did = _dogId();
|
API.training.getProgress().then(rows => {
|
||||||
_progressLoaded = false;
|
|
||||||
API.training.getProgress(_did)
|
|
||||||
.then(rows => {
|
|
||||||
_progressCache = {};
|
|
||||||
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
||||||
_progressLoaded = true;
|
// localStorage-Daten migrieren falls noch nicht im Backend
|
||||||
_renderContent();
|
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
|
||||||
}).catch(() => { _progressLoaded = true; _renderContent(); });
|
const parts = lsKey.replace('ub_status_', '').split('_');
|
||||||
|
const tab = parts[0];
|
||||||
|
const name = parts.slice(1).join('_');
|
||||||
|
const apiKey = `${tab}_${name}`;
|
||||||
|
if (_progressCache[apiKey] === undefined) {
|
||||||
|
const val = localStorage.getItem(lsKey);
|
||||||
|
if (val) {
|
||||||
|
_progressCache[apiKey] = val;
|
||||||
|
API.training.setProgress(apiKey, val).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_renderContent(); // Re-render with loaded progress
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Empfehlungen laden
|
// Empfehlungen laden
|
||||||
API.training.getSuggestions(_did).then(suggestions => {
|
API.training.getSuggestions().then(suggestions => {
|
||||||
if (suggestions.length) _showSuggestions(suggestions);
|
if (suggestions.length) _showSuggestions(suggestions);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
|
@ -543,10 +555,6 @@ window.Page_uebungen = (() => {
|
||||||
function onDogChange() {
|
function onDogChange() {
|
||||||
_statsData = null;
|
_statsData = null;
|
||||||
_badgesData = null;
|
_badgesData = null;
|
||||||
_progressCache = {};
|
|
||||||
_progressLoaded = false;
|
|
||||||
_exerciseStats = {};
|
|
||||||
_render();
|
|
||||||
_loadStatsAndBadges();
|
_loadStatsAndBadges();
|
||||||
_loadVirtualTrainer();
|
_loadVirtualTrainer();
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +565,6 @@ window.Page_uebungen = (() => {
|
||||||
function _render() {
|
function _render() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div id="ueb-wrap">
|
<div id="ueb-wrap">
|
||||||
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
|
|
||||||
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
|
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
|
||||||
<table style="width:100%;border-collapse:collapse">
|
<table style="width:100%;border-collapse:collapse">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -594,7 +601,6 @@ window.Page_uebungen = (() => {
|
||||||
<div id="ueb-content"></div>
|
<div id="ueb-content"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
UI.bindDogChip(_container, _appState);
|
|
||||||
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
|
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
|
||||||
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
|
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
|
||||||
_container.querySelector('#ueb-search')?.addEventListener('input', e => {
|
_container.querySelector('#ueb-search')?.addEventListener('input', e => {
|
||||||
|
|
@ -604,12 +610,7 @@ window.Page_uebungen = (() => {
|
||||||
_renderContent();
|
_renderContent();
|
||||||
});
|
});
|
||||||
_bindTabs();
|
_bindTabs();
|
||||||
if (_progressLoaded) {
|
|
||||||
_renderContent();
|
_renderContent();
|
||||||
} else {
|
|
||||||
const el = _container.querySelector('#ueb-content');
|
|
||||||
if (el) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"><svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg></div>`;
|
|
||||||
}
|
|
||||||
_renderStatsBanner();
|
_renderStatsBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,18 +779,7 @@ window.Page_uebungen = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// SCHNELL-SETUP: Stand aller Übungen erfassen
|
// SCHNELL-SETUP: Stand aller Übungen erfassen
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _openQuickSetupModal() {
|
function _openQuickSetupModal() {
|
||||||
// Sicherstellen dass Progress geladen ist bevor das Modal öffnet
|
|
||||||
if (!_progressLoaded) {
|
|
||||||
const did = _dogId();
|
|
||||||
try {
|
|
||||||
const rows = await API.training.getProgress(did);
|
|
||||||
_progressCache = {};
|
|
||||||
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
|
||||||
_progressLoaded = true;
|
|
||||||
_renderContent();
|
|
||||||
} catch { _progressLoaded = true; }
|
|
||||||
}
|
|
||||||
const ALL = [
|
const ALL = [
|
||||||
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
|
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
|
||||||
{ group: 'Tricks', tab: 'tricks', items: TRICKS },
|
{ group: 'Tricks', tab: 'tricks', items: TRICKS },
|
||||||
|
|
@ -890,8 +880,11 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
// Alle geänderten Status speichern
|
// Alle geänderten Status speichern
|
||||||
const parts = Object.entries(pending).map(([key, val]) => {
|
const parts = Object.entries(pending).map(([key, val]) => {
|
||||||
|
const [tab, ...rest] = key.split('_');
|
||||||
|
const name = rest.join('_').replace(/_/g, ' ');
|
||||||
_progressCache[key] = val || null;
|
_progressCache[key] = val || null;
|
||||||
return API.training.setProgress(key, val || null, _dogId());
|
localStorage.setItem(`ub_status_${key}`, val || '');
|
||||||
|
return API.training.setProgress(key, val || null);
|
||||||
});
|
});
|
||||||
await Promise.allSettled(parts);
|
await Promise.allSettled(parts);
|
||||||
|
|
||||||
|
|
@ -1011,17 +1004,7 @@ window.Page_uebungen = (() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
|
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
|
||||||
case 'ki-trainer':
|
case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break;
|
||||||
if (!App.hasPro(_appState?.user)) {
|
|
||||||
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
|
|
||||||
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
|
|
||||||
<div style="font-size:var(--text-sm)">Der KI-Trainer ist ein Pro-Feature.</div>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
el.innerHTML = _renderKiTrainer();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
_bindAccordions();
|
_bindAccordions();
|
||||||
_bindStatusButtons();
|
_bindStatusButtons();
|
||||||
|
|
|
||||||
|
|
@ -397,9 +397,7 @@ window.Page_wetter = (() => {
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const locName = _data.location_name ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">${_esc(_data.location_name)}</div>` : '';
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
${locName}
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||||
${_wmoIcon(d.weathercode, '3.5rem')}
|
${_wmoIcon(d.weathercode, '3.5rem')}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -79,34 +79,15 @@ const UI = (() => {
|
||||||
|
|
||||||
document.getElementById('modal-container').appendChild(overlay);
|
document.getElementById('modal-container').appendChild(overlay);
|
||||||
document.documentElement.classList.add('modal-open');
|
document.documentElement.classList.add('modal-open');
|
||||||
|
_current = { overlay, onClose };
|
||||||
// Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
|
|
||||||
let _vvCleanup = null;
|
|
||||||
const vv = window.visualViewport;
|
|
||||||
if (vv) {
|
|
||||||
const adjust = () => {
|
|
||||||
const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
|
||||||
overlay.style.paddingBottom = (kb + 16) + 'px';
|
|
||||||
};
|
|
||||||
vv.addEventListener('resize', adjust);
|
|
||||||
vv.addEventListener('scroll', adjust);
|
|
||||||
_vvCleanup = () => {
|
|
||||||
vv.removeEventListener('resize', adjust);
|
|
||||||
vv.removeEventListener('scroll', adjust);
|
|
||||||
overlay.style.paddingBottom = '';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_current = { overlay, onClose, _vvCleanup };
|
|
||||||
|
|
||||||
return overlay.querySelector('.modal');
|
return overlay.querySelector('.modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!_current) return;
|
if (!_current) return;
|
||||||
const { onClose, _vvCleanup } = _current;
|
const { onClose } = _current;
|
||||||
onClose?.();
|
onClose?.();
|
||||||
_vvCleanup?.();
|
|
||||||
_current.overlay.remove();
|
_current.overlay.remove();
|
||||||
document.documentElement.classList.remove('modal-open');
|
document.documentElement.classList.remove('modal-open');
|
||||||
_current = null;
|
_current = null;
|
||||||
|
|
@ -995,30 +976,6 @@ const UI = (() => {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dogChip(appState) {
|
|
||||||
const dog = appState?.activeDog;
|
|
||||||
const dogs = appState?.dogs || [];
|
|
||||||
if (!dog) return '';
|
|
||||||
const av = dog.foto_url
|
|
||||||
? `<img src="${escape(dog.foto_url)}" style="width:22px;height:22px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
|
||||||
: `<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
|
||||||
const sw = dogs.length > 1
|
|
||||||
? `<svg class="ph-icon" style="width:13px;height:13px;color:var(--c-text-muted);margin-left:2px"><use href="/icons/phosphor.svg#arrows-left-right"></use></svg>` : '';
|
|
||||||
return `<div class="by-dog-chip" data-dog-chip style="display:inline-flex;align-items:center;gap:6px;
|
|
||||||
padding:4px 10px 4px 6px;background:var(--c-surface-2);border:1px solid var(--c-border);
|
|
||||||
border-radius:100px;font-size:var(--text-xs);font-weight:600;color:var(--c-text);
|
|
||||||
${dogs.length > 1 ? 'cursor:pointer' : ''};max-width:fit-content">${av}<span>${escape(dog.name)}</span>${sw}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindDogChip(container, appState) {
|
|
||||||
if ((appState?.dogs?.length || 0) < 2) return;
|
|
||||||
container.querySelector('[data-dog-chip]')?.addEventListener('click', () => {
|
|
||||||
const dogs = appState.dogs;
|
|
||||||
const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0];
|
|
||||||
if (next) App.setActiveDog(next.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
toast, modal,
|
toast, modal,
|
||||||
|
|
@ -1033,10 +990,6 @@ const UI = (() => {
|
||||||
leafletMarker,
|
leafletMarker,
|
||||||
locationPicker,
|
locationPicker,
|
||||||
ratingStars,
|
ratingStars,
|
||||||
dogChip,
|
|
||||||
bindDogChip,
|
|
||||||
dogChip,
|
|
||||||
bindDogChip,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -346,8 +346,7 @@ window.Worlds = (() => {
|
||||||
<div class="w3-section-label">${worldLabels[w]}</div>
|
<div class="w3-section-label">${worldLabels[w]}</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||||||
${chips.map(c => `
|
${chips.map(c => `
|
||||||
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
|
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}">
|
||||||
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>` : ''}
|
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -524,7 +523,7 @@ window.Worlds = (() => {
|
||||||
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
|
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
|
||||||
{ icon:'push-pin', label:'Forum', page:'forum',
|
{ icon:'push-pin', label:'Forum', page:'forum',
|
||||||
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
|
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
|
||||||
{ icon:'users', label:'Freunde', page:'friends',
|
{ icon:'users', label:'Freunde', page:'friends', pro: true,
|
||||||
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
|
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
|
||||||
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
|
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
|
||||||
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
|
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
|
||||||
|
|
@ -761,7 +760,6 @@ window.Worlds = (() => {
|
||||||
<use href="/icons/phosphor.svg#lock-simple"></use>
|
<use href="/icons/phosphor.svg#lock-simple"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`}
|
</div>`}
|
||||||
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
|
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -971,24 +969,10 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
// ── CHIP-HELPER ──────────────────────────────────────────────
|
// ── CHIP-HELPER ──────────────────────────────────────────────
|
||||||
|
|
||||||
function _isRoleBasedPro() {
|
function _chip(icon, label, page, locked = false) {
|
||||||
const u = _state?.user;
|
|
||||||
if (!u) return false;
|
|
||||||
const t = u.subscription_tier || 'standard';
|
|
||||||
if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false;
|
|
||||||
return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _chip(icon, label, page, locked = false, proBadge = false) {
|
|
||||||
const style = locked ? 'opacity:0.25;cursor:default;' : '';
|
const style = locked ? 'opacity:0.25;cursor:default;' : '';
|
||||||
const badge = proBadge
|
|
||||||
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
|
|
||||||
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
|
|
||||||
: '';
|
|
||||||
return `
|
return `
|
||||||
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
|
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`} style="${style}">
|
||||||
style="${style}position:relative">
|
|
||||||
${badge}
|
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -1113,11 +1097,7 @@ window.Worlds = (() => {
|
||||||
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
|
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
|
||||||
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
|
${w ? `<span style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:1px;white-space:nowrap">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</span>` : ''}
|
||||||
${w.location_name ? `<div style="color:rgba(255,255,255,0.5)">${w.location_name}</div>` : ''}
|
|
||||||
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
|
||||||
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1139,7 +1119,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Deine Bereiche</div>
|
<div class="world-section-label">Deine Bereiche</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}
|
${features.map(f => _chip(f.icon, f.label, f.page)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="impressum">Impressum</span>
|
<span data-wnav="impressum">Impressum</span>
|
||||||
|
|
@ -1430,7 +1410,7 @@ window.Worlds = (() => {
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
|
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="gruender">Die 100 Gründer</span>
|
<span data-wnav="gruender">Die 100 Gründer</span>
|
||||||
|
|
@ -1438,12 +1418,8 @@ window.Worlds = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Avatar → Hundeprofil (aktiven Hund auf den angezeigten setzen)
|
// Avatar → Hundeprofil
|
||||||
el.querySelector('#wh-avatar')?.addEventListener('click', () => {
|
el.querySelector('#wh-avatar')?.addEventListener('click', () => navigateTo('dog-profile'));
|
||||||
const shown = _dogs[_dogIdx];
|
|
||||||
if (shown && shown.id !== _state?.activeDog?.id) App.setActiveDog(shown.id);
|
|
||||||
navigateTo('dog-profile');
|
|
||||||
});
|
|
||||||
// Chips
|
// Chips
|
||||||
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
|
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
|
||||||
// Name → nächster Hund
|
// Name → nächster Hund
|
||||||
|
|
@ -1604,7 +1580,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Die Welt da draußen</div>
|
<div class="world-section-label">Die Welt da draußen</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="datenschutz">Datenschutz</span>
|
<span data-wnav="datenschutz">Datenschutz</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Allow: /info
|
||||||
|
Allow: /wiki/rassen
|
||||||
|
Allow: /wiki/rasse/
|
||||||
|
Allow: /hund/
|
||||||
|
Allow: /breeder/
|
||||||
|
Allow: /wurfboerse
|
||||||
|
Allow: /knigge
|
||||||
Disallow: /api/
|
Disallow: /api/
|
||||||
Disallow: /ausweis/
|
Disallow: /ausweis/
|
||||||
Disallow: /teilen/
|
Disallow: /teilen/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v872';
|
const CACHE_VERSION = 'by-v826';
|
||||||
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
|
||||||
|
|
@ -123,7 +123,6 @@ const _CACHEABLE_GET = [
|
||||||
/^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
|
/^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
|
||||||
/^\/api\/training\/exercises/,
|
/^\/api\/training\/exercises/,
|
||||||
/^\/api\/training\/progress/,
|
/^\/api\/training\/progress/,
|
||||||
/^\/api\/training\/plan-progress/,
|
|
||||||
/^\/api\/wiki\/rassen/,
|
/^\/api\/wiki\/rassen/,
|
||||||
/^\/api\/dogs\/\d+\/diary\/stats/,
|
/^\/api\/dogs\/\d+\/diary\/stats/,
|
||||||
// Drei Welten — offline-fähig
|
// Drei Welten — offline-fähig
|
||||||
|
|
@ -138,17 +137,13 @@ function _isCacheableGet(pathname) {
|
||||||
|
|
||||||
// Cache-TTL: stabile Daten länger, dynamische kürzer
|
// Cache-TTL: stabile Daten länger, dynamische kürzer
|
||||||
const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/];
|
const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/];
|
||||||
const _SHORT_GET = [/^\/api\/training\/progress/, /^\/api\/training\/plan-progress/];
|
|
||||||
const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
|
const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
|
||||||
const _TTL_SHORT = 30 * 1000; // 30 Sekunden (Fortschritte, hund-spezifisch)
|
|
||||||
const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
|
const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
|
||||||
|
|
||||||
const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
|
const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
|
||||||
|
|
||||||
function _cacheTTL(pathname) {
|
function _cacheTTL(pathname) {
|
||||||
if (_STABLE_GET.some(re => re.test(pathname))) return _TTL_STABLE;
|
return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT;
|
||||||
if (_SHORT_GET.some(re => re.test(pathname))) return _TTL_SHORT;
|
|
||||||
return _TTL_DEFAULT;
|
|
||||||
}
|
}
|
||||||
function _cacheStale(pathname) {
|
function _cacheStale(pathname) {
|
||||||
const ts = _cacheTs.get(pathname);
|
const ts = _cacheTs.get(pathname);
|
||||||
|
|
@ -364,12 +359,6 @@ self.addEventListener('message', event => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.data?.type === 'INVALIDATE_CACHE') {
|
|
||||||
// Cache-Timestamps für angegebene Pfade zurücksetzen → nächster Request geht ans Netz
|
|
||||||
const paths = event.data.paths || [];
|
|
||||||
paths.forEach(p => _cacheTs.delete(p));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.data?.type === 'PROCESS_QUEUE') {
|
if (event.data?.type === 'PROCESS_QUEUE') {
|
||||||
event.waitUntil(_processQueue());
|
event.waitUntil(_processQueue());
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ BAN YARO — Wetter via Open-Meteo
|
||||||
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
|
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -59,36 +58,15 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
f"?latitude={lat}&longitude={lon}"
|
f"?latitude={lat}&longitude={lon}"
|
||||||
"¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
|
"¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
|
||||||
"&daily=precipitation_probability_max,uv_index_max"
|
"&daily=precipitation_probability_max,uv_index_max"
|
||||||
"&hourly=precipitation_probability"
|
|
||||||
"&timezone=Europe%2FBerlin&forecast_days=1"
|
"&timezone=Europe%2FBerlin&forecast_days=1"
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
resp, geo_resp = await asyncio.gather(
|
resp = await client.get(url)
|
||||||
client.get(url),
|
|
||||||
client.get(
|
|
||||||
f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
|
|
||||||
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
|
|
||||||
),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
raw = resp.json()
|
raw = resp.json()
|
||||||
|
|
||||||
# Ortsname aus Reverse-Geocoding
|
|
||||||
location_name = None
|
|
||||||
try:
|
|
||||||
if not isinstance(geo_resp, Exception) and geo_resp.status_code == 200:
|
|
||||||
geo = geo_resp.json()
|
|
||||||
addr = geo.get("address", {})
|
|
||||||
location_name = (addr.get("city") or addr.get("town") or
|
|
||||||
addr.get("village") or addr.get("municipality") or
|
|
||||||
addr.get("county") or geo.get("name"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cur = raw.get('current', {})
|
cur = raw.get('current', {})
|
||||||
daily = raw.get('daily', {})
|
daily = raw.get('daily', {})
|
||||||
hourly = raw.get('hourly', {})
|
|
||||||
|
|
||||||
temp = cur.get('temperature_2m')
|
temp = cur.get('temperature_2m')
|
||||||
feels_like = cur.get('apparent_temperature')
|
feels_like = cur.get('apparent_temperature')
|
||||||
|
|
@ -107,39 +85,6 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
if temp is not None and temp > 7.0 and 3 <= month <= 10:
|
if temp is not None and temp > 7.0 and 3 <= month <= 10:
|
||||||
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
|
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
|
||||||
|
|
||||||
# Nächste Regenstunde + Umschwung-Warnung
|
|
||||||
next_rain_time = None
|
|
||||||
rain_warning_time = None # Stunde mit ≥40%-Sprung gegenüber Vorststunde
|
|
||||||
already_raining = wcode >= 51
|
|
||||||
now_h = datetime.now().hour
|
|
||||||
h_times = hourly.get('time', [])
|
|
||||||
h_precip = hourly.get('precipitation_probability', [])
|
|
||||||
|
|
||||||
# Index-Liste nur für Stunden im Fenster now_h+1 … now_h+12
|
|
||||||
window = []
|
|
||||||
for t, p in zip(h_times, h_precip):
|
|
||||||
try:
|
|
||||||
entry_h = int(t[11:13])
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if entry_h <= now_h or entry_h > now_h + 12:
|
|
||||||
continue
|
|
||||||
window.append((entry_h, p if p is not None else 0))
|
|
||||||
|
|
||||||
if not already_raining:
|
|
||||||
for entry_h, p in window:
|
|
||||||
if next_rain_time is None and p >= 20:
|
|
||||||
next_rain_time = f"{entry_h:02d}:00"
|
|
||||||
break
|
|
||||||
|
|
||||||
# Umschwung: Sprung ≥40% von einer Stunde zur nächsten
|
|
||||||
prev_p = wcode >= 51 and 100 or (h_precip[now_h] if now_h < len(h_precip) else 0) or 0
|
|
||||||
for entry_h, p in window:
|
|
||||||
if p - prev_p >= 40:
|
|
||||||
rain_warning_time = f"{entry_h:02d}:00"
|
|
||||||
break
|
|
||||||
prev_p = p
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'temp_c': temp,
|
'temp_c': temp,
|
||||||
'feels_like_c': feels_like,
|
'feels_like_c': feels_like,
|
||||||
|
|
@ -151,9 +96,6 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
'uv_index': uv,
|
'uv_index': uv,
|
||||||
'is_day': bool(is_day),
|
'is_day': bool(is_day),
|
||||||
'zecken_warnung': zecken,
|
'zecken_warnung': zecken,
|
||||||
'next_rain_time': next_rain_time,
|
|
||||||
'rain_warning_time': rain_warning_time,
|
|
||||||
'location_name': location_name,
|
|
||||||
}
|
}
|
||||||
_location_cache[key] = (now, data)
|
_location_cache[key] = (now, data)
|
||||||
return data
|
return data
|
||||||
|
|
@ -293,23 +235,9 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
forecast_task = client.get(forecast_url)
|
forecast_task = client.get(forecast_url)
|
||||||
pollen_task = client.get(pollen_url)
|
pollen_task = client.get(pollen_url)
|
||||||
geo_task = client.get(
|
forecast_resp, pollen_resp = await asyncio.gather(
|
||||||
f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
|
forecast_task, pollen_task, return_exceptions=True
|
||||||
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
|
|
||||||
)
|
)
|
||||||
forecast_resp, pollen_resp, geo_resp_fc = await asyncio.gather(
|
|
||||||
forecast_task, pollen_task, geo_task, return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
location_name_fc = None
|
|
||||||
try:
|
|
||||||
if not isinstance(geo_resp_fc, Exception) and geo_resp_fc.status_code == 200:
|
|
||||||
addr = geo_resp_fc.json().get("address", {})
|
|
||||||
location_name_fc = (addr.get("city") or addr.get("town") or
|
|
||||||
addr.get("village") or addr.get("municipality") or
|
|
||||||
addr.get("county"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# --- Forecast (required) ---
|
# --- Forecast (required) ---
|
||||||
if isinstance(forecast_resp, Exception):
|
if isinstance(forecast_resp, Exception):
|
||||||
|
|
@ -456,7 +384,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
'hourly': _hourly_by_day.get(date_str, []),
|
'hourly': _hourly_by_day.get(date_str, []),
|
||||||
})
|
})
|
||||||
|
|
||||||
result = {'timezone': timezone, 'days': days, 'location_name': location_name_fc}
|
result = {'timezone': timezone, 'days': days}
|
||||||
_forecast_cache[key] = (now, result)
|
_forecast_cache[key] = (now, result)
|
||||||
_log_forecast(round(lat, 1), round(lon, 1), days)
|
_log_forecast(round(lat, 1), round(lon, 1), days)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/banyaro.db
|
- DB_PATH=/data/banyaro.db
|
||||||
- MEDIA_DIR=/data/media
|
- MEDIA_DIR=/prod-media
|
||||||
- STAGING=true
|
- STAGING=true
|
||||||
- KI_MODE=cloud
|
- KI_MODE=cloud
|
||||||
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue