Compare commits

..

No commits in common. "26b515cede5d3d5ca808e43820f65f2d0431e2c0" and "15d319fbd53d97c327252957122eb11bf7eab9be" have entirely different histories.

123 changed files with 3476 additions and 5534 deletions

View file

@ -8,11 +8,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Non-root User für Container-Hardening
# (Synology DSM-Volumes haben ACLs — daher chown auf /data + /app)
RUN groupadd -r appuser -g 1000 && \
useradd -r -u 1000 -g appuser -d /app -s /sbin/nologin appuser
# Python-Dependencies zuerst (Docker Layer Cache) # Python-Dependencies zuerst (Docker Layer Cache)
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@ -27,12 +22,6 @@ COPY VERSION /app/VERSION
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
/data/media/breeds/gallery /data/media/breeds/submissions /data/media/breeds/gallery /data/media/breeds/submissions
# USER appuser auskommentiert: Synology DSM Volume-ACLs blockieren das
# (SQLite OperationalError: 'attempt to write a readonly database'). User-
# Anlage bleibt im Dockerfile damit nicht-DS-Deployments später wechseln
# können via `USER appuser` Zeile auskommentieren-entfernen.
# USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]

View file

@ -287,8 +287,7 @@ bump:
sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \ sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \
sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \ sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \ sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \ echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)"
# ---------------------------------------------------------- # ----------------------------------------------------------
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)

View file

@ -1 +1 @@
1120 1099

View file

@ -212,49 +212,6 @@ def require_admin(user=Depends(get_current_user)):
return user return user
def require_moderator(user=Depends(get_current_user)):
"""Dependency: Admin oder Moderator. Konsequente Nutzung statt
Inline-`if user['rolle'] not in (...):` in den Routen."""
if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.")
return user
def require_breeder(user=Depends(get_current_user)):
"""Dependency: Admin oder Züchter (breeder/breeder_test)."""
if user["rolle"] == "admin":
return user
if user.get("subscription_tier") in ("breeder", "breeder_test"):
return user
raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.")
# ------------------------------------------------------------------
# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403`
# ------------------------------------------------------------------
def require_owner(row, user: dict, owner_field: str = "user_id",
not_found_msg: str = "Nicht gefunden",
forbidden_msg: str = "Kein Zugriff"):
"""Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer.
Returns row für chainability:
dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden')
"""
if not row:
raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg)
if row[owner_field] != user["id"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg)
return row
def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool:
"""True wenn User Owner ist oder Admin/Moderator."""
if not row:
return False
if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"):
return True
return row[owner_field] == user["id"]
def has_pro_access(user: dict) -> bool: def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf.""" """True wenn User Pro-Features nutzen darf."""
if not user: if not user:

View file

@ -1,20 +0,0 @@
"""Zentrale Konfiguration — vermeidet 19× duplizierte os.getenv-Aufrufe
für MEDIA_DIR und gibt einheitliche Timeout-Konstanten für externe APIs."""
import os
# Speicher-Pfade
DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db")
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
SCANINPUT_DIR = os.getenv("SCANINPUT_DIR", "/data/scaninput")
# HTTP-Timeouts für externe APIs (in Sekunden)
# Verwendung: httpx.AsyncClient(timeout=API_TIMEOUT_DEFAULT)
API_TIMEOUT_SHORT = 5 # Schnelle Lookups (Geocoding, Reverse, einzelne Werte)
API_TIMEOUT_DEFAULT = 10 # Standardfall (Wetter, Wikipedia)
API_TIMEOUT_LONG = 30 # Größere Antworten (Overpass-Tiles, KI-Calls)
# Standard-Header für externe Requests (Höflichkeit + Fair-Use)
HTTP_USER_AGENT = "BanYaro/1.0 (https://banyaro.app)"
HTTP_HEADERS = {"User-Agent": HTTP_USER_AGENT}

View file

@ -1,47 +0,0 @@
"""Standardisierte HTTP-Exceptions — vermeidet inkonsistente Texte
in 200+ raise-Statements."""
from fastapi import HTTPException
def not_found(msg: str = "Nicht gefunden") -> HTTPException:
"""404. Beispiel: `raise not_found('Hund nicht gefunden')`."""
return HTTPException(404, msg)
def forbidden(msg: str = "Kein Zugriff") -> HTTPException:
"""403."""
return HTTPException(403, msg)
def bad_request(msg: str = "Ungültige Eingabe") -> HTTPException:
"""400."""
return HTTPException(400, msg)
def unauthorized(msg: str = "Nicht angemeldet") -> HTTPException:
"""401."""
return HTTPException(401, msg)
def conflict(msg: str = "Konflikt") -> HTTPException:
"""409."""
return HTTPException(409, msg)
def too_many_requests(msg: str = "Zu viele Anfragen", retry_after: int | None = None) -> HTTPException:
"""429. Optional mit Retry-After Header (in Sekunden)."""
headers = {"Retry-After": str(retry_after)} if retry_after else None
return HTTPException(429, msg, headers=headers)
def service_unavailable(msg: str = "Dienst gerade nicht verfügbar") -> HTTPException:
"""503."""
return HTTPException(503, msg)
def require_or_404(row, msg: str = "Nicht gefunden"):
"""Convenience: wirft 404 wenn row None/falsy, sonst gibt row zurück.
Beispiel: `dog = require_or_404(conn.execute(...).fetchone(), 'Hund nicht gefunden')`"""
if not row:
raise not_found(msg)
return row

View file

@ -110,8 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt) "style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"connect-src 'self' https:; " "connect-src 'self' https:; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
@ -1763,40 +1763,19 @@ async def force_update():
<title>Ban Yaro Update</title> <title>Ban Yaro Update</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center; <style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px} height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px} p{color:#94a3b8;font-size:14px}</style></head>
button{margin-top:24px;background:#C4843A;color:#fff;border:none;padding:12px 24px;
border-radius:8px;font-size:16px;cursor:pointer}</style></head>
<body> <body>
<div> Einen Moment</div> <div> Einen Moment</div>
<p id="s">Wir besorgen neue Leckerlis 🦴</p> <p id="s">Wir besorgen neue Leckerlis 🦴</p>
<button id="b" style="display:none" onclick="location.replace('/?_t='+Date.now())">App neu starten</button>
<script> <script>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1'); sessionStorage.setItem('by_skip_sw_reload','1');
// Cleanup IM HINTERGRUND starten (fire-and-forget) kein await, // Fire-and-forget kein await, Reload nach spätestens 1.5s
// kein Blockieren. Selbst wenn die Promises nie resolven (iOS-Bug), try{
// hängen wir nicht. navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{});
try { caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{});
if (navigator.serviceWorker) { }catch(e){}
navigator.serviceWorker.getRegistrations() setTimeout(()=>location.replace('/'),1500);
.then(r => r.forEach(s => s.unregister().catch(() => {})))
.catch(() => {});
}
if (window.caches) {
caches.keys()
.then(k => k.forEach(c => caches.delete(c).catch(() => {})))
.catch(() => {});
}
} catch(e) {}
// Sofort reload keine Promise-Abhängigkeit
setTimeout(() => location.replace('/?_t=' + Date.now()), 150);
// Fallback: falls Reload nach 3s noch nicht passiert ist
// (z.B. SW intercepted), Button anzeigen für manuellen Tap
setTimeout(() => { document.getElementById('b').style.display = ''; }, 3000);
// Fallback 2: nach 6s automatisch nochmal versuchen mit hartem reload
setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
</script></body></html>""" </script></body></html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})

View file

@ -1,37 +0,0 @@
"""Mathematische Helper-Funktionen — zentral statt 13× dupliziert."""
import math
# Erdradius in Kilometern
EARTH_RADIUS_KM = 6371.0
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz zwischen zwei GPS-Koordinaten in km (Haversine-Formel).
Funktioniert für beliebige Punkte auf der Erde. Genauigkeit reicht
für App-Zwecke (Umkreissuche etc.).
"""
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz in Metern (Convenience-Wrapper)."""
return haversine_km(lat1, lon1, lat2, lon2) * 1000.0
def bbox_deg_from_km(lat: float, radius_km: float):
"""Bounding-Box-Approximation in Grad für radius_km um (lat, lon).
Returns (lat_delta, lon_delta) beide in Grad.
Verwendung: WHERE lat BETWEEN ?-lat_delta AND ?+lat_delta etc.
"""
lat_delta = radius_km / 111.0
lon_delta = radius_km / (111.0 * max(abs(math.cos(math.radians(lat))), 0.01))
return lat_delta, lon_delta

View file

@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db, DB_PATH from database import db, DB_PATH
from auth import get_current_user from auth import get_current_user
@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree
class QuarterlyReportBody(BaseModel): class QuarterlyReportBody(BaseModel):
year: int year: int
quarter: int quarter: int
email: str = Field(..., max_length=254) email: str
class UserPatch(BaseModel): class UserPatch(BaseModel):
rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None is_moderator: Optional[int] = None
is_banned: Optional[int] = None is_banned: Optional[int] = None
ban_reason: Optional[str] = Field(None, max_length=1000) ban_reason: Optional[str] = None
is_social_media: Optional[int] = None is_social_media: Optional[int] = None
subscription_tier: Optional[str] = Field(None, max_length=50) subscription_tier: Optional[str] = None
class WikiEnrichBody(BaseModel): class WikiEnrichBody(BaseModel):
limit: int = 10 limit: int = 10

View file

@ -10,18 +10,18 @@ Caching: adoption_cache Tabelle, 24h TTL.
""" """
import os import os
import math
import logging import logging
import asyncio import asyncio
import uuid import uuid
import httpx import httpx
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException
from pydantic import BaseModel, Field 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 from auth import get_current_user
from routes.push import send_push_to_user from routes.push import send_push_to_user
from math_utils import haversine_km
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -31,6 +31,18 @@ router = APIRouter()
PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "")
# ------------------------------------------------------------------
# Haversine — Distanz in km
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Statische Tierheim-Daten (große deutsche Tierheime) # Statische Tierheim-Daten (große deutsche Tierheime)
@ -222,7 +234,7 @@ async def adoption_nearby(
for row in rows: for row in rows:
d = dict(row) d = dict(row)
if d.get("tierheim_lat") and d.get("tierheim_lon"): if d.get("tierheim_lat") and d.get("tierheim_lon"):
dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
if dist <= radius: if dist <= radius:
d["distanz_km"] = round(dist, 1) d["distanz_km"] = round(dist, 1)
cached_animals.append(d) cached_animals.append(d)
@ -238,7 +250,7 @@ async def adoption_nearby(
# ------ Statische Tierheime (immer) ------ # ------ Statische Tierheime (immer) ------
shelters = [] shelters = []
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
dist = haversine_km(lat, lon, slat, slon) dist = _haversine(lat, lon, slat, slon)
if dist <= radius: if dist <= radius:
shelters.append({ shelters.append({
"id": sid, "id": sid,
@ -292,7 +304,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
# ================================================================== # ==================================================================
class InterestBody(BaseModel): class InterestBody(BaseModel):
nachricht: Optional[str] = Field(None, max_length=5000) nachricht: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -342,7 +354,7 @@ def community_list(
d = dict(row) d = dict(row)
d["user_interested"] = bool(d.pop("_user_interested", 0)) d["user_interested"] = bool(d.pop("_user_interested", 0))
if lat is not None and lon is not None and d.get("lat") and d.get("lon"): if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
dist = haversine_km(lat, lon, d["lat"], d["lon"]) dist = _haversine(lat, lon, d["lat"], d["lon"])
d["distanz_km"] = round(dist, 1) d["distanz_km"] = round(dist, 1)
if dist > radius: if dist > radius:
continue continue
@ -422,7 +434,7 @@ async def community_create(
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class _StatusBody(BaseModel): class _StatusBody(BaseModel):
status: str = Field(..., max_length=50) status: str
@router.patch("/community/{listing_id}") @router.patch("/community/{listing_id}")
def community_update_status( def community_update_status(

View file

@ -1,10 +1,10 @@
"""BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)""" """BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)"""
import math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from database import db from database import db
from auth import get_current_user_optional as get_optional_user from auth import get_current_user_optional as get_optional_user
from math_utils import haversine_m, bbox_deg_from_km
router = APIRouter() router = APIRouter()
@ -12,9 +12,21 @@ _RADIUS_M = 20_000 # 20 km
_RADIUS_KM = _RADIUS_M / 1000.0 _RADIUS_KM = _RADIUS_M / 1000.0
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]: def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]:
"""Bounding-Box-Approximation für lat/lon innerhalb radius_km.""" """Bounding-Box-Approximation für lat/lon innerhalb radius_km."""
lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km) lat_delta = radius_km / 111.0
# cos darf bei Polen nicht 0 werden → mit kleinem Minimum absichern
cos_lat = max(abs(math.cos(math.radians(lat))), 0.01)
lon_delta = radius_km / (111.0 * cos_lat)
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
@ -48,7 +60,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user))
(lat, lon, user["id"]) (lat, lon, user["id"])
) )
has_poison = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons) has_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons)
has_lost = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost) has_lost = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
return {"poison": has_poison, "lost": has_lost} return {"poison": has_poison, "lost": has_lost}

View file

@ -10,7 +10,7 @@ from typing import Optional
import jwt as _pyjwt import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr
from database import db from database import db
from auth import ( from auth import (
hash_password, verify_password, create_token, hash_password, verify_password, create_token,
@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(..., min_length=1, max_length=200) password: str
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(..., min_length=8, max_length=200) password: str
name: str = Field(..., min_length=2, max_length=40) name: str
ref_code: Optional[str] = Field(None, max_length=50) ref_code: Optional[str] = None
def _gen_referral_code() -> str: def _gen_referral_code() -> str:
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
email: EmailStr email: EmailStr
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel):
token: str = Field(..., min_length=10, max_length=200) token: str
password: str = Field(..., min_length=8, max_length=200) password: str
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request): async def forgot_password(data: ForgotPasswordRequest, request: Request):
@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
class UpgradeRequestBody(BaseModel): class UpgradeRequestBody(BaseModel):
tier: str = Field(..., max_length=50) tier: str
message: Optional[str] = Field(None, max_length=2000) message: Optional[str] = None
@router.post("/upgrade-request") @router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):

View file

@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
@ -237,7 +237,7 @@ async def admin_download_document(user_id: int, doc_id: int, admin=Depends(requi
class RejectBody(BaseModel): class RejectBody(BaseModel):
grund: str = Field(..., min_length=3, max_length=2000) grund: str
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -483,13 +483,13 @@ async def admin_create_profile(admin=Depends(require_admin)):
# PUT /api/breeder/profile — eigenes Profil bearbeiten # PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel): class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = Field(None, max_length=200) zwingername: Optional[str] = None
rasse_text: Optional[str] = Field(None, max_length=200) rasse_text: Optional[str] = None
verein: Optional[str] = Field(None, max_length=200) verein: Optional[str] = None
vdh_mitglied: Optional[int] = None vdh_mitglied: Optional[int] = None
stadt: Optional[str] = Field(None, max_length=200) stadt: Optional[str] = None
website: Optional[str] = Field(None, max_length=500) website: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
@router.put("/breeder/profile") @router.put("/breeder/profile")
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):

View file

@ -1,7 +1,7 @@
"""BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)""" """BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
import os, logging, asyncio import os, logging, asyncio
from database import db from database import db
@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)):
# Modelle # Modelle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VisibilityBody(BaseModel): class VisibilityBody(BaseModel):
visibility: str = Field(..., max_length=30) visibility: str
class CaptionBody(BaseModel): class CaptionBody(BaseModel):
caption: Optional[str] = Field(None, max_length=500) caption: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import os
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -142,7 +142,7 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
class SendMsgModel(BaseModel): class SendMsgModel(BaseModel):
text: str = Field(..., min_length=1, max_length=2000) text: str
@router.post("/conversations/{conv_id}/messages", status_code=201) @router.post("/conversations/{conv_id}/messages", status_code=201)
@ -151,6 +151,8 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren
text = data.text.strip() text = data.text.strip()
if not text: if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.") raise HTTPException(400, "Nachricht darf nicht leer sein.")
if len(text) > 2000:
raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).")
with db() as conn: with db() as conn:
conv = conn.execute( conv = conn.execute(

View file

@ -1,8 +1,8 @@
"""BAN YARO — Tagebuch Routes""" """BAN YARO — Tagebuch Routes"""
import os, uuid, json, logging, asyncio import os, uuid, json, math, logging, asyncio
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field 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, require_admin from auth import get_current_user, require_admin
@ -11,7 +11,6 @@ import httpx
import weather as weather_mod import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time from timeutils import safe_client_time
from math_utils import haversine_km
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,27 +19,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel): class DiaryCreate(BaseModel):
datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute datum: Optional[str] = None # ISO date, default heute
client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = Field("eintrag", max_length=50) typ: str = "eintrag"
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
text: Optional[str] = Field(None, max_length=10000) text: Optional[str] = None
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: Optional[float] = None gps_lon: Optional[float] = None
location_name: Optional[str] = Field(None, max_length=300) location_name: Optional[str] = None
is_milestone: bool = False is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel): class DiaryUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
text: Optional[str] = Field(None, max_length=10000) text: Optional[str] = None
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: Optional[float] = None gps_lon: Optional[float] = None
location_name: Optional[str] = Field(None, max_length=300) location_name: Optional[str] = None
is_milestone: Optional[bool] = None is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
@ -410,7 +409,7 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list
elat = el.get("lat") or el.get("center", {}).get("lat") elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon: if elat and elon:
km = haversine_km(lat, lon, elat, elon) km = _haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in typ = next((el["tags"].get(k) for k in
["tourism", "historic", "leisure", "amenity", "shop"] ["tourism", "historic", "leisure", "amenity", "shop"]
if el["tags"].get(k)), "place") if el["tags"].get(k)), "place")
@ -423,6 +422,16 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list
return results[:limit] return results[:limit]
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
@router.get("/{dog_id}/diary/nearby") @router.get("/{dog_id}/diary/nearby")
async def nearby_places(dog_id: int, lat: float, lon: float, async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)): user=Depends(get_current_user)):
@ -436,7 +445,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
(user["id"],) (user["id"],)
).fetchall() ).fetchall()
for p in places: for p in places:
km = haversine_km(lat, lon, p["lat"], p["lon"]) km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5: if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place", results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"], "lat": p["lat"], "lon": p["lon"],
@ -447,7 +456,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall() ).fetchall()
for p in osm: for p in osm:
km = haversine_km(lat, lon, p["lat"], p["lon"]) km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2: if km <= 2:
results.append({"name": p["name"], "type": p["type"], results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"], "lat": p["lat"], "lon": p["lon"],
@ -494,7 +503,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
elat = el.get("lat") or el.get("center", {}).get("lat") elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon: if elat and elon:
km = haversine_km(lat, lon, elat, elon) km = _haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in typ = next((el["tags"].get(k) for k in
["tourism","historic","leisure","amenity","shop"] ["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place") if el["tags"].get(k)), "place")

View file

@ -3,7 +3,7 @@
import os import os
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field 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, has_pro_access
@ -29,27 +29,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DogCreate(BaseModel): class DogCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80) name: str
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
geburtstag: Optional[str] = Field(None, max_length=32) geburtstag: Optional[str] = None
geschlecht: Optional[str] = Field(None, max_length=20) geschlecht: Optional[str] = None
gewicht_kg: Optional[float] = None gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
bio: Optional[str] = Field(None, max_length=2000) bio: Optional[str] = None
is_public: bool = False is_public: bool = False
class DogUpdate(BaseModel): class DogUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=80) name: Optional[str] = None
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
rasse_id: Optional[int] = None rasse_id: Optional[int] = None
geburtstag: Optional[str] = Field(None, max_length=32) geburtstag: Optional[str] = None
geschlecht: Optional[str] = Field(None, max_length=20) geschlecht: Optional[str] = None
gewicht_kg: Optional[float] = None gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
bio: Optional[str] = Field(None, max_length=2000) bio: Optional[str] = None
is_public: Optional[bool] = None is_public: Optional[bool] = None
@ -180,21 +180,13 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)):
if dog_count == 1: # genau dieser erste Hund if dog_count == 1: # genau dieser erste Hund
plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag)
if plausible: if plausible:
# Atomare Gründer-Vergabe — Race-frei via Sub-Query im UPDATE. total = conn.execute(
# Wenn schon 100 Founder oder User schon is_founder=1 → kein Update (rowcount=0) "SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total < 100:
conn.execute( conn.execute(
"""UPDATE users "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?",
SET is_founder = 1, (total + 1, user["id"])
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
),
is_founder_pending = 0
WHERE id = ?
AND is_founder_pending = 1
AND (is_founder IS NULL OR is_founder = 0)
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
(user["id"],)
) )
return dict(dog) return dict(dog)
@ -1033,8 +1025,8 @@ async def public_dog_profile(dog_id: int):
class FoundReport(BaseModel): class FoundReport(BaseModel):
message: Optional[str] = Field(None, max_length=1000) message: Optional[str] = None
kontakt: Optional[str] = Field(None, max_length=300) kontakt: Optional[str] = None
# Gefunden-Meldung (kein Login nötig) # Gefunden-Meldung (kein Login nötig)
@ -1319,7 +1311,7 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren # POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class GedenkenData(BaseModel): class GedenkenData(BaseModel):
verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD verstorben_am: str # YYYY-MM-DD
@router.post("/{dog_id}/gedenken") @router.post("/{dog_id}/gedenken")
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)): async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -16,18 +16,18 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel): class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix futter_typ: Optional[str] = None # trocken|nass|barf|mix
marke: Optional[str] = Field(None, max_length=200) marke: Optional[str] = None
kcal_tag: Optional[int] = None kcal_tag: Optional[int] = None
portionen: Optional[int] = None portionen: Optional[int] = None
notizen: Optional[str] = Field(None, max_length=5000) notizen: Optional[str] = None
class KiBeratungRequest(BaseModel): class KiBeratungRequest(BaseModel):
frage: str = Field(..., min_length=3, max_length=2000) frage: str
dog_name: Optional[str] = Field(None, max_length=80) dog_name: Optional[str] = None
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
alter: Optional[str] = Field(None, max_length=50) alter: Optional[str] = None
gewicht: Optional[float] = None gewicht: Optional[float] = None
aktiv: Optional[bool] = None aktiv: Optional[bool] = None
@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige
class FutterEintragCreate(BaseModel): class FutterEintragCreate(BaseModel):
datum: str = Field(..., max_length=32) datum: str
uhrzeit: str = Field(..., max_length=20) uhrzeit: str
futter_name: str = Field(..., max_length=200) futter_name: str
futter_typ: Optional[str] = Field("trockenfutter", max_length=50) futter_typ: Optional[str] = "trockenfutter"
menge_g: Optional[int] = None menge_g: Optional[int] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class ReaktionCreate(BaseModel): class ReaktionCreate(BaseModel):
datum: str = Field(..., max_length=32) datum: str
uhrzeit: str = Field(..., max_length=20) uhrzeit: str
reaktion_typ: str = Field(..., max_length=100) reaktion_typ: str
intensitaet: Optional[int] = 3 intensitaet: Optional[int] = 3
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,45 +1,54 @@
"""BAN YARO — Events (Hundeveranstaltungen)""" """BAN YARO — Events (Hundeveranstaltungen)"""
import math
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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 from auth import get_current_user
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'}
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RsvpCreate(BaseModel): class RsvpCreate(BaseModel):
status: str = Field('going', max_length=20) # 'going' | 'maybe' status: str = 'going' # 'going' | 'maybe'
class EventCreate(BaseModel): class EventCreate(BaseModel):
titel: str = Field(..., min_length=3, max_length=200) titel: str
datum: str = Field(..., max_length=32) # YYYY-MM-DD datum: str # YYYY-MM-DD
uhrzeit: Optional[str] = Field(None, max_length=20) uhrzeit: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
typ: str = Field('sonstiges', max_length=50) typ: str = 'sonstiges'
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
link: Optional[str] = Field(None, max_length=500) link: Optional[str] = None
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
uhrzeit: Optional[str] = Field(None, max_length=20) uhrzeit: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
typ: Optional[str] = Field(None, max_length=50) typ: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
link: Optional[str] = Field(None, max_length=500) link: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -77,7 +86,7 @@ async def list_events(
result = [dict(r) for r in rows] result = [dict(r) for r in rows]
if lat is not None and lon is not None: if lat is not None and lon is not None:
result = [r for r in result result = [r for r in result
if r['lat'] is None or haversine_m(lat, lon, r['lat'], r['lon']) <= radius] if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius]
return result return result

View file

@ -4,7 +4,7 @@ import logging
from datetime import date, timedelta from datetime import date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -20,35 +20,35 @@ KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonst
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ExpenseCreate(BaseModel): class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: str = Field(..., max_length=50) kategorie: str
betrag: float betrag: float
datum: str = Field(..., max_length=32) datum: str
notiz: Optional[str] = Field(None, max_length=1000) notiz: Optional[str] = None
class ExpenseUpdate(BaseModel): class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: Optional[str] = Field(None, max_length=50) kategorie: Optional[str] = None
betrag: Optional[float] = None betrag: Optional[float] = None
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=1000) notiz: Optional[str] = None
class RecurringCreate(BaseModel): class RecurringCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: str = Field(..., max_length=50) kategorie: str
betrag: float betrag: float
haeufigkeit: str = Field(..., max_length=30) # monatlich | quartalsweise | jaehrlich haeufigkeit: str # monatlich | quartalsweise | jaehrlich
startdatum: str = Field(..., max_length=32) # ISO date startdatum: str # ISO date
notiz: Optional[str] = Field(None, max_length=1000) notiz: Optional[str] = None
class RecurringUpdate(BaseModel): class RecurringUpdate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: Optional[str] = Field(None, max_length=50) kategorie: Optional[str] = None
betrag: Optional[float] = None betrag: Optional[float] = None
haeufigkeit: Optional[str] = Field(None, max_length=30) haeufigkeit: Optional[str] = None
startdatum: Optional[str] = Field(None, max_length=32) startdatum: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=1000) notiz: Optional[str] = None
aktiv: Optional[bool] = None aktiv: Optional[bool] = None

View file

@ -2,7 +2,7 @@
import os, uuid, json, logging import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field 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, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -27,40 +27,40 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ThreadCreate(BaseModel): class ThreadCreate(BaseModel):
kategorie: str = Field('allgemein', max_length=100) kategorie: str = 'allgemein'
titel: str = Field(..., min_length=3, max_length=200) titel: str
text: str = Field(..., min_length=1, max_length=10000) text: str
thread_lat: Optional[float] = None thread_lat: Optional[float] = None
thread_lon: Optional[float] = None thread_lon: Optional[float] = None
thread_ort: Optional[str] = Field(None, max_length=300) thread_ort: Optional[str] = None
client_time: Optional[str] = Field(None, max_length=64) client_time: Optional[str] = None
class PostCreate(BaseModel): class PostCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=10000) text: str
client_time: Optional[str] = Field(None, max_length=64) client_time: Optional[str] = None
class ThreadPatch(BaseModel): class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None is_pinned: Optional[int] = None
is_locked: Optional[int] = None is_locked: Optional[int] = None
class ThreadUpdate(BaseModel): class ThreadUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
text: Optional[str] = Field(None, max_length=10000) text: Optional[str] = None
thread_lat: Optional[float] = None thread_lat: Optional[float] = None
thread_lon: Optional[float] = None thread_lon: Optional[float] = None
thread_ort: Optional[str] = Field(None, max_length=300) thread_ort: Optional[str] = None
class PostUpdate(BaseModel): class PostUpdate(BaseModel):
text: str = Field(..., min_length=1, max_length=10000) text: str
class LikeBody(BaseModel): class LikeBody(BaseModel):
target_type: str = Field(..., max_length=20) # 'thread' | 'post' target_type: str # 'thread' | 'post'
target_id: int target_id: int
class ReportBody(BaseModel): class ReportBody(BaseModel):
target_type: str = Field(..., max_length=20) target_type: str
target_id: int target_id: int
grund: str = Field(..., min_length=3, max_length=1000) grund: str
class LocationBody(BaseModel): class LocationBody(BaseModel):
lat: Optional[float] = None lat: Optional[float] = None

View file

@ -1,28 +1,37 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" """BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json import json
import math
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_m
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
class GassiZeitCreate(BaseModel): class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"] wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str = Field(..., max_length=20) # "17:00" uhrzeit: str # "17:00"
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
radius_m: int = 500 radius_m: int = 500
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class GassiZeitUpdate(BaseModel): class GassiZeitUpdate(BaseModel):
@ -74,7 +83,7 @@ async def list_gassi_zeiten(
# Distanz-Filter # Distanz-Filter
if lat is not None and lon is not None and d.get("lat") and d.get("lon"): if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
dist = haversine_m(lat, lon, d["lat"], d["lon"]) dist = _haversine(lat, lon, d["lat"], d["lon"])
if not nur_eigene and dist > radius: if not nur_eigene and dist > radius:
continue continue
d["distance_m"] = int(dist) d["distance_m"] = int(dist)

View file

@ -3,7 +3,7 @@
import os, uuid import os, uuid
from datetime import date, datetime from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class HealthCreate(BaseModel): class HealthCreate(BaseModel):
typ: str = Field(..., max_length=50) typ: str
bezeichnung: Optional[str] = Field(None, max_length=200) bezeichnung: Optional[str] = None
datum: str = Field(..., max_length=32) datum: str
naechstes: Optional[str] = Field(None, max_length=32) naechstes: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=5000) notiz: Optional[str] = None
# Gewicht # Gewicht
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = Field("kg", max_length=20) einheit: Optional[str] = "kg"
# Impfung # Impfung
charge_nr: Optional[str] = Field(None, max_length=100) charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = Field(None, max_length=200) tierarzt_name: Optional[str] = None
# Tierarztbesuch # Tierarztbesuch
kosten: Optional[float] = None kosten: Optional[float] = None
diagnose: Optional[str] = Field(None, max_length=2000) diagnose: Optional[str] = None
# Medikament # Medikament
dosierung: Optional[str] = Field(None, max_length=200) dosierung: Optional[str] = None
haeufigkeit: Optional[str] = Field(None, max_length=200) haeufigkeit: Optional[str] = None
aktiv: Optional[int] = 1 aktiv: Optional[int] = 1
bis_datum: Optional[str] = Field(None, max_length=32) bis_datum: Optional[str] = None
# Allergie # Allergie
schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer schweregrad: Optional[str] = None # leicht | mittel | schwer
reaktion: Optional[str] = Field(None, max_length=1000) reaktion: Optional[str] = None
erinnerung: Optional[int] = 1 erinnerung: Optional[int] = 1
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
# Tierarzt-Verknüpfung # Tierarzt-Verknüpfung
tierarzt_id: Optional[int] = None tierarzt_id: Optional[int] = None
# Züchter # Züchter
deckdatum: Optional[str] = Field(None, max_length=32) deckdatum: Optional[str] = None
wurftermin: Optional[str] = Field(None, max_length=32) wurftermin: Optional[str] = None
class HealthUpdate(BaseModel): class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = Field(None, max_length=200) bezeichnung: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
naechstes: Optional[str] = Field(None, max_length=32) naechstes: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=5000) notiz: Optional[str] = None
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = Field(None, max_length=20) einheit: Optional[str] = None
charge_nr: Optional[str] = Field(None, max_length=100) charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = Field(None, max_length=200) tierarzt_name: Optional[str] = None
kosten: Optional[float] = None kosten: Optional[float] = None
diagnose: Optional[str] = Field(None, max_length=2000) diagnose: Optional[str] = None
dosierung: Optional[str] = Field(None, max_length=200) dosierung: Optional[str] = None
haeufigkeit: Optional[str] = Field(None, max_length=200) haeufigkeit: Optional[str] = None
aktiv: Optional[int] = None aktiv: Optional[int] = None
bis_datum: Optional[str] = Field(None, max_length=32) bis_datum: Optional[str] = None
schweregrad: Optional[str] = Field(None, max_length=50) schweregrad: Optional[str] = None
reaktion: Optional[str] = Field(None, max_length=1000) reaktion: Optional[str] = None
erinnerung: Optional[int] = None erinnerung: Optional[int] = None
intervall_tage: Optional[int] = None intervall_tage: Optional[int] = None
tierarzt_id: Optional[int] = None tierarzt_id: Optional[int] = None
deckdatum: Optional[str] = Field(None, max_length=32) deckdatum: Optional[str] = None
wurftermin: Optional[str] = Field(None, max_length=32) wurftermin: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -390,7 +390,7 @@ async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel): class SymptomCheckRequest(BaseModel):
symptoms: str = Field(..., min_length=3, max_length=5000) symptoms: str
@router.post("/{dog_id}/health/symptom-check") @router.post("/{dog_id}/health/symptom-check")
@ -576,20 +576,20 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
# ================================================================== # ==================================================================
class InsuranceCreate(BaseModel): class InsuranceCreate(BaseModel):
anbieter: str = Field(..., min_length=1, max_length=200) anbieter: str
police_nr: Optional[str] = Field(None, max_length=100) police_nr: Optional[str] = None
jahresbeitrag: Optional[float] = None jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = Field(None, max_length=500) kontakt: Optional[str] = None
ablaufdatum: Optional[str] = Field(None, max_length=32) ablaufdatum: Optional[str] = None
notizen: Optional[str] = Field(None, max_length=5000) notizen: Optional[str] = None
class InsuranceUpdate(BaseModel): class InsuranceUpdate(BaseModel):
anbieter: Optional[str] = Field(None, max_length=200) anbieter: Optional[str] = None
police_nr: Optional[str] = Field(None, max_length=100) police_nr: Optional[str] = None
jahresbeitrag: Optional[float] = None jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = Field(None, max_length=500) kontakt: Optional[str] = None
ablaufdatum: Optional[str] = Field(None, max_length=32) ablaufdatum: Optional[str] = None
notizen: Optional[str] = Field(None, max_length=5000) notizen: Optional[str] = None
@router.get("/{dog_id}/insurance") @router.get("/{dog_id}/insurance")
@ -674,12 +674,12 @@ TRIGGER_LABELS = {
class BehaviorCreate(BaseModel): class BehaviorCreate(BaseModel):
datum: str = Field(..., max_length=32) datum: str
uhrzeit: Optional[str] = Field(None, max_length=20) uhrzeit: Optional[str] = None
kategorie: str = Field(..., max_length=50) kategorie: str
intensitaet: int = 3 intensitaet: int = 3
trigger: Optional[str] = Field(None, max_length=200) trigger: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=5000) notiz: Optional[str] = None
@router.get("/{dog_id}/behavior") @router.get("/{dog_id}/behavior")

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hilfe / FAQ Routes""" """BAN YARO — Hilfe / FAQ Routes"""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field 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_optional, require_admin from auth import get_current_user_optional, require_admin
@ -31,17 +31,17 @@ def _load_active_help_articles() -> list[dict]:
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ArticleCreate(BaseModel): class ArticleCreate(BaseModel):
kategorie: str = Field(..., max_length=100) kategorie: str
frage: str = Field(..., min_length=3, max_length=500) frage: str
antwort: str = Field(..., min_length=3, max_length=10000) antwort: str
sort_order: int = 0 sort_order: int = 0
aktiv: int = 1 aktiv: int = 1
class ArticleUpdate(BaseModel): class ArticleUpdate(BaseModel):
kategorie: Optional[str] = Field(None, max_length=100) kategorie: Optional[str] = None
frage: Optional[str] = Field(None, max_length=500) frage: Optional[str] = None
antwort: Optional[str] = Field(None, max_length=10000) antwort: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
aktiv: Optional[int] = None aktiv: Optional[int] = None

View file

@ -6,7 +6,7 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import require_admin from auth import require_admin
import mailer import mailer
@ -19,30 +19,30 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class InvoiceItem(BaseModel): class InvoiceItem(BaseModel):
description: str = Field(..., max_length=500) description: str
quantity: float = 1.0 quantity: float = 1.0
unit_price: float unit_price: float
class InvoiceCreate(BaseModel): class InvoiceCreate(BaseModel):
user_id: Optional[int] = None user_id: Optional[int] = None
recipient_name: str = Field(..., max_length=200) recipient_name: str
recipient_email: str = Field(..., max_length=254) recipient_email: str
recipient_address: Optional[str] = Field(None, max_length=500) recipient_address: Optional[str] = None
items: List[InvoiceItem] items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0 discount_pct: Optional[float] = 0.0
service_period: Optional[str] = Field(None, max_length=200) service_period: Optional[str] = None
notes: Optional[str] = Field(None, max_length=5000) notes: Optional[str] = None
class PayBody(BaseModel): class PayBody(BaseModel):
paid_at: str = Field(..., max_length=32) paid_at: str
paid_amount: float paid_amount: float
notes: Optional[str] = Field(None, max_length=2000) notes: Optional[str] = None
class CancelBody(BaseModel): class CancelBody(BaseModel):
reason: str = Field(..., min_length=3, max_length=1000) reason: str
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
"""BAN YARO — KI Routes""" """BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
import ki as ki_module import ki as ki_module
from auth import get_current_user from auth import get_current_user
@ -11,9 +11,9 @@ router = APIRouter()
class TrainingRequest(BaseModel): class TrainingRequest(BaseModel):
problem: str = Field(..., min_length=10, max_length=1000) problem: str
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
alter: Optional[str] = Field(None, max_length=50) alter: Optional[str] = None
@router.post("/training") @router.post("/training")
@ -23,6 +23,8 @@ async def ki_training(req: TrainingRequest, request: Request,
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training") rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
if not req.problem or len(req.problem.strip()) < 10: if not req.problem or len(req.problem.strip()) < 10:
raise HTTPException(400, "Bitte beschreibe das Problem genauer.") raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
if len(req.problem) > 1000:
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
rasse = req.rasse or "unbekannt" rasse = req.rasse or "unbekannt"
alter = req.alter or "unbekannt" alter = req.alter or "unbekannt"
@ -67,10 +69,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
# POST /ki/tierarzt — KI-Tierarztfragen # POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TierarztRequest(BaseModel): class TierarztRequest(BaseModel):
symptom: str = Field(..., min_length=5, max_length=1000) symptom: str
dog_id: Optional[int] = None dog_id: Optional[int] = None
dog_name: Optional[str] = Field(None, max_length=80) dog_name: Optional[str] = None
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
@router.post("/tierarzt") @router.post("/tierarzt")
@ -79,6 +81,8 @@ async def ki_tierarzt(req: TierarztRequest, request: Request,
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5: if not req.symptom or len(req.symptom.strip()) < 5:
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
if len(req.symptom) > 1000:
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
# Rate-Limit: max 5 Anfragen pro User pro Tag # Rate-Limit: max 5 Anfragen pro User pro Tag
with db() as conn: with db() as conn:
@ -169,10 +173,10 @@ def _log_rasse_request(user_id: int):
class BirthdayRequest(BaseModel): class BirthdayRequest(BaseModel):
dog_id: int dog_id: int
name: str = Field(..., max_length=80) name: str
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
alter: Optional[int] = None alter: Optional[int] = None
mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today" mode: str = "tomorrow" # "tomorrow" | "today"
@router.post("/geburtstag") @router.post("/geburtstag")
async def ki_geburtstag(req: BirthdayRequest, request: Request, async def ki_geburtstag(req: BirthdayRequest, request: Request,
@ -364,12 +368,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class AbschiedRequest(BaseModel): class AbschiedRequest(BaseModel):
dog_id: int dog_id: int
name: str = Field(..., max_length=80) name: str
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
km_total: Optional[float] = None km_total: Optional[float] = None
diary_count: Optional[int] = None diary_count: Optional[int] = None
gemeinsam_tage: Optional[int] = None gemeinsam_tage: Optional[int] = None
last_entry_titel: Optional[str] = Field(None, max_length=200) last_entry_titel: Optional[str] = None
@router.post("/abschied") @router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request, async def ki_abschied(req: AbschiedRequest, request: Request,

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Knigge Routes""" """BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field 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, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -13,12 +13,12 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VoteRequest(BaseModel): class VoteRequest(BaseModel):
szenario_id: str = Field(..., max_length=100) szenario_id: str
answer: str = Field(..., max_length=100) answer: str
class KiRatRequest(BaseModel): class KiRatRequest(BaseModel):
situation: str = Field(..., min_length=3, max_length=2000) situation: str
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,7 +1,7 @@
"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)""" """BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import date, timedelta from datetime import date, timedelta
@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list:
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LaeufiCreate(BaseModel): class LaeufiCreate(BaseModel):
beginn: str = Field(..., max_length=32) beginn: str
ende: Optional[str] = Field(None, max_length=32) ende: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class LaeufiUpdate(BaseModel): class LaeufiUpdate(BaseModel):
beginn: Optional[str] = Field(None, max_length=32) beginn: Optional[str] = None
ende: Optional[str] = Field(None, max_length=32) ende: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class ProgestCreate(BaseModel): class ProgestCreate(BaseModel):
datum: str = Field(..., max_length=32) datum: str
wert: Optional[float] = None wert: Optional[float] = None
einheit: str = Field("ng/ml", max_length=20) einheit: str = "ng/ml"
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class ProgestUpdate(BaseModel): class ProgestUpdate(BaseModel):
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = Field(None, max_length=20) einheit: Optional[str] = None
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class DeckCreate(BaseModel): class DeckCreate(BaseModel):
deckdatum: str = Field(..., max_length=32) deckdatum: str
laeufi_id: Optional[int] = None laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200) ruede_name: Optional[str] = None
deckart: str = Field("natuerlich", max_length=50) deckart: str = "natuerlich"
traechtig: int = 0 traechtig: int = 0
ultraschall_datum: Optional[str] = Field(None, max_length=32) ultraschall_datum: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class DeckUpdate(BaseModel): class DeckUpdate(BaseModel):
deckdatum: Optional[str] = Field(None, max_length=32) deckdatum: Optional[str] = None
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200) ruede_name: Optional[str] = None
deckart: Optional[str] = Field(None, max_length=50) deckart: Optional[str] = None
traechtig: Optional[int] = None traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = Field(None, max_length=32) ultraschall_datum: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import logging
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
@ -27,68 +27,68 @@ def _require_breeder(user=Depends(get_current_user)):
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LitterCreate(BaseModel): class LitterCreate(BaseModel):
wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C … wurf_rang: Optional[str] = None # A, B, C …
wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf" wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = Field(None, max_length=200) vater_name: Optional[str] = None
mutter_name: Optional[str] = Field(None, max_length=200) mutter_name: Optional[str] = None
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
geburt_datum: Optional[str] = Field(None, max_length=32) geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = Field(None, max_length=32) erwartetes_datum: Optional[str] = None
welpen_gesamt: Optional[int] = None welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = Field(None, max_length=5000) gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = Field(None, max_length=100) preis_spanne: Optional[str] = None
status: str = Field("geplant", max_length=30) status: str = "geplant"
sichtbar: int = 0 sichtbar: int = 0
sichtbar_bis: Optional[str] = Field(None, max_length=32) sichtbar_bis: Optional[str] = None
class LitterUpdate(BaseModel): class LitterUpdate(BaseModel):
wurf_rang: Optional[str] = Field(None, max_length=10) wurf_rang: Optional[str] = None
wurf_name: Optional[str] = Field(None, max_length=200) wurf_name: Optional[str] = None
vater_name: Optional[str] = Field(None, max_length=200) vater_name: Optional[str] = None
mutter_name: Optional[str] = Field(None, max_length=200) mutter_name: Optional[str] = None
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
geburt_datum: Optional[str] = Field(None, max_length=32) geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = Field(None, max_length=32) erwartetes_datum: Optional[str] = None
welpen_gesamt: Optional[int] = None welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = Field(None, max_length=5000) gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = Field(None, max_length=100) preis_spanne: Optional[str] = None
status: Optional[str] = Field(None, max_length=30) status: Optional[str] = None
sichtbar: Optional[int] = None sichtbar: Optional[int] = None
sichtbar_bis: Optional[str] = Field(None, max_length=32) sichtbar_bis: Optional[str] = None
class PuppyCreate(BaseModel): class PuppyCreate(BaseModel):
name: Optional[str] = Field(None, max_length=80) name: Optional[str] = None
geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich geschlecht: Optional[str] = None # maennlich|weiblich
farbe: Optional[str] = Field(None, max_length=100) farbe: Optional[str] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
geburtsgewicht: Optional[float] = None # Gramm geburtsgewicht: Optional[float] = None # Gramm
status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben
status_sichtbar: int = 1 status_sichtbar: int = 1
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class PuppyUpdate(BaseModel): class PuppyUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=80) name: Optional[str] = None
geschlecht: Optional[str] = Field(None, max_length=20) geschlecht: Optional[str] = None
farbe: Optional[str] = Field(None, max_length=100) farbe: Optional[str] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
geburtsgewicht: Optional[float] = None geburtsgewicht: Optional[float] = None
status: Optional[str] = Field(None, max_length=30) status: Optional[str] = None
status_sichtbar: Optional[int] = None status_sichtbar: Optional[int] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class WeightEntry(BaseModel): class WeightEntry(BaseModel):
gewicht_g: float gewicht_g: float
gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD gemessen_am: str # YYYY-MM-DD
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -663,15 +663,15 @@ async def generate_contract(
# Warteliste # Warteliste
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class WaitlistEntry(BaseModel): class WaitlistEntry(BaseModel):
name: str = Field(..., min_length=1, max_length=200) name: str
email: Optional[str] = Field(None, max_length=254) email: Optional[str] = None
telefon: Optional[str] = Field(None, max_length=30) telefon: Optional[str] = None
nachricht: Optional[str] = Field(None, max_length=5000) nachricht: Optional[str] = None
wunsch_geschlecht: str = Field("egal", max_length=20) wunsch_geschlecht: str = "egal"
wunsch_farbe: Optional[str] = Field(None, max_length=100) wunsch_farbe: Optional[str] = None
prioritaet: int = 0 prioritaet: int = 0
status: str = Field("anfrage", max_length=30) status: str = "anfrage"
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
class WaitlistUpdate(BaseModel): class WaitlistUpdate(BaseModel):

View file

@ -1,32 +1,44 @@
"""BAN YARO — Verlorener Hund Routes""" """BAN YARO — Verlorener Hund Routes"""
import os, uuid import os, uuid, math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field 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 from auth import get_current_user
from timeutils import safe_client_time from timeutils import safe_client_time
from routes.push import send_push_to_all from routes.push import send_push_to_all
from media_utils import convert_media from media_utils import convert_media
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LostDogCreate(BaseModel): class LostDogCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80) name: str
rasse: Optional[str] = Field(None, max_length=80) rasse: Optional[str] = None
beschreibung: str = Field(..., min_length=3, max_length=5000) beschreibung: str
lat: float lat: float
lon: float lon: float
dog_id: Optional[int] = None dog_id: Optional[int] = None
client_time: Optional[str] = Field(None, max_length=64) client_time: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -48,7 +60,7 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
for r in rows: for r in rows:
entry = dict(r) entry = dict(r)
if lat is not None and lon is not None: if lat is not None and lon is not None:
dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000: if dist > radius_km * 1000:
continue continue
entry["distanz_m"] = round(dist) entry["distanz_m"] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Filme Routes""" """BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from database import db from database import db
@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel):
dog_id: int dog_id: int
class MovieCreate(BaseModel): class MovieCreate(BaseModel):
id: str = Field(..., max_length=100) id: str
titel: str = Field(..., min_length=1, max_length=200) titel: str
originaltitel: Optional[str] = Field(None, max_length=200) originaltitel: Optional[str] = None
jahr: Optional[int] = None jahr: Optional[int] = None
genre: Optional[str] = Field(None, max_length=100) genre: Optional[str] = None
typ: str = Field("film", max_length=30) typ: str = "film"
hund_rasse: Optional[str] = Field(None, max_length=200) hund_rasse: Optional[str] = None
stirbt_der_hund: bool = False stirbt_der_hund: bool = False
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
bild_emoji: str = Field("🐾", max_length=10) bild_emoji: str = "🐾"
imdb_rating: Optional[float] = None imdb_rating: Optional[float] = None
streaming: Optional[str] = Field(None, max_length=500) streaming: Optional[str] = None
class MovieUpdate(BaseModel): class MovieUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
originaltitel: Optional[str] = Field(None, max_length=200) originaltitel: Optional[str] = None
jahr: Optional[int] = None jahr: Optional[int] = None
genre: Optional[str] = Field(None, max_length=100) genre: Optional[str] = None
typ: Optional[str] = Field(None, max_length=30) typ: Optional[str] = None
hund_rasse: Optional[str] = Field(None, max_length=200) hund_rasse: Optional[str] = None
stirbt_der_hund: Optional[bool] = None stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
bild_emoji: Optional[str] = Field(None, max_length=10) bild_emoji: Optional[str] = None
imdb_rating: Optional[float] = None imdb_rating: Optional[float] = None
streaming: Optional[str] = Field(None, max_length=500) streaming: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import json
import logging import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, Any, List from typing import Optional, Any, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -18,18 +18,18 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class NoteCreate(BaseModel): class NoteCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=5000) text: str
meta_json: Optional[Any] = None meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300) location_name: Optional[str] = None
parent_label: Optional[str] = Field(None, max_length=200) parent_label: Optional[str] = None
client_time: Optional[str] = Field(None, max_length=64) client_time: Optional[str] = None
class NoteUpdate(BaseModel): class NoteUpdate(BaseModel):
text: Optional[str] = Field(None, max_length=5000) text: Optional[str] = None
meta_json: Optional[Any] = None meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300) location_name: Optional[str] = None
parent_label: Optional[str] = Field(None, max_length=200) parent_label: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -9,7 +9,7 @@ import httpx
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user, get_current_user_optional as get_optional_user from auth import get_current_user, get_current_user_optional as get_optional_user
@ -110,7 +110,7 @@ async def _fetch_overpass(query):
except Exception as exc: except Exception as exc:
logger.warning(f"Overpass Verbindungsfehler {url}: {exc}") logger.warning(f"Overpass Verbindungsfehler {url}: {exc}")
break # nächste URL break # nächste URL
raise HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.") raise Exception("Alle Overpass-Instanzen fehlgeschlagen")
def _stale_tiles(poi_type, tiles): def _stale_tiles(poi_type, tiles):
stale = [] stale = []
@ -273,11 +273,11 @@ async def get_pois(
# POST /user-poi — Community-Marker setzen # POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class UserPoiIn(BaseModel): class UserPoiIn(BaseModel):
type: str = Field(..., max_length=200) type: str
lat: float lat: float
lon: float lon: float
name: Optional[str] = Field(None, max_length=300) name: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
ALLOWED_TYPES = { ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park', 'waste_basket', 'drinking_water', 'dog_park',
@ -331,8 +331,8 @@ async def delete_user_poi(poi_id: int, user = Depends(get_current_user)):
# POST /report — Marker als ungültig melden # POST /report — Marker als ungültig melden
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReportIn(BaseModel): class ReportIn(BaseModel):
type: str = Field(..., max_length=100) type: str
grund: str = Field(..., max_length=200) grund: str
osm_id: Optional[int] = None osm_id: Optional[int] = None
user_poi_id: Optional[int] = None user_poi_id: Optional[int] = None
@ -388,9 +388,9 @@ async def analyze_region(
# POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor # POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PoiEditCreate(BaseModel): class PoiEditCreate(BaseModel):
poi_name: str = Field(..., max_length=300) poi_name: str
field: str = Field('opening_hours', max_length=50) field: str = 'opening_hours'
new_value: str = Field(..., max_length=1000) new_value: str
@router.post('/pois/{osm_id}/edit', status_code=201) @router.post('/pois/{osm_id}/edit', status_code=201)

View file

@ -13,7 +13,7 @@ from typing import List, Optional
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from auth import require_admin from auth import require_admin
from database import db from database import db
@ -135,25 +135,25 @@ def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TemplateIn(BaseModel): class TemplateIn(BaseModel):
key: str = Field(..., max_length=100) key: str
label: str = Field(..., max_length=200) label: str
subject: str = Field(..., max_length=500) subject: str
body: str = Field(..., max_length=50000) body: str
from_account: str = Field("partner", max_length=50) from_account: str = "partner"
class TemplateUpdate(BaseModel): class TemplateUpdate(BaseModel):
label: str = Field(..., max_length=200) label: str
subject: str = Field(..., max_length=500) subject: str
body: str = Field(..., max_length=50000) body: str
from_account: str = Field("partner", max_length=50) from_account: str = "partner"
class SendRequest(BaseModel): class SendRequest(BaseModel):
to: List[str] to: List[str]
subject: str = Field(..., max_length=500) subject: str
body: str = Field(..., max_length=50000) body: str
from_account: str = Field("partner", max_length=50) from_account: str = "partner"
template_id: Optional[int] = None template_id: Optional[int] = None

View file

@ -2,7 +2,7 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import require_admin, get_current_user from auth import require_admin, get_current_user
@ -10,8 +10,8 @@ router = APIRouter()
class PartnerCodeCreate(BaseModel): class PartnerCodeCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=50) code: str
label: str = Field(..., min_length=1, max_length=200) label: str
grants_founder: int = 1 grants_founder: int = 1
max_uses: Optional[int] = None max_uses: Optional[int] = None
@ -93,29 +93,16 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm
if not target: if not target:
raise HTTPException(404, "User nicht gefunden.") raise HTTPException(404, "User nicht gefunden.")
if updates.get("is_founder") == 1 and not target["founder_number"]: if updates.get("is_founder") == 1 and not target["founder_number"]:
# Atomare Gründer-Vergabe — kein TOCTOU mehr zwischen COUNT und UPDATE. # Neue Gründer-Nummer zuweisen
# Sub-Query wird gegen Snapshot vor dem UPDATE evaluiert (SQL-Spec). total = conn.execute(
cur = conn.execute( "SELECT COUNT(*) FROM users WHERE is_founder=1"
"""UPDATE users ).fetchone()[0]
SET is_founder = 1, if total >= FOUNDER_MAX:
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
)
WHERE id = ?
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < ?
AND (is_founder IS NULL OR is_founder = 0)""",
(user_id, FOUNDER_MAX)
)
if cur.rowcount == 0:
raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.")
# is_founder + founder_number sind atomar gesetzt — aus updates entfernen updates["founder_number"] = total + 1
updates.pop("is_founder", None)
updates.pop("founder_number", None)
elif updates.get("is_founder") == 0: elif updates.get("is_founder") == 0:
# Gründer-Status entfernen → founder_number ebenfalls leeren # Gründer-Status entfernen → founder_number ebenfalls leeren
updates["founder_number"] = None updates["founder_number"] = None
if updates: # nach atomarer Founder-Vergabe ggf. leer
set_clause = ", ".join(f"{k}=?" for k in updates) set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute( conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?", f"UPDATE users SET {set_clause} WHERE id=?",

View file

@ -5,7 +5,7 @@ import secrets
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -17,25 +17,25 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PassportMeta(BaseModel): class PassportMeta(BaseModel):
blutgruppe: Optional[str] = Field(None, max_length=50) blutgruppe: Optional[str] = None
allergien: Optional[str] = Field(None, max_length=2000) allergien: Optional[str] = None
besonderheiten: Optional[str] = Field(None, max_length=2000) besonderheiten: Optional[str] = None
class VaccinationCreate(BaseModel): class VaccinationCreate(BaseModel):
krankheit: str = Field(..., max_length=200) krankheit: str
datum: str = Field(..., max_length=32) datum: str
naechste: Optional[str] = Field(None, max_length=32) naechste: Optional[str] = None
tierarzt: Optional[str] = Field(None, max_length=200) tierarzt: Optional[str] = None
charge_nr: Optional[str] = Field(None, max_length=100) charge_nr: Optional[str] = None
class MedicationCreate(BaseModel): class MedicationCreate(BaseModel):
name: str = Field(..., max_length=200) name: str
dosierung: Optional[str] = Field(None, max_length=200) dosierung: Optional[str] = None
von: Optional[str] = Field(None, max_length=32) von: Optional[str] = None
bis: Optional[str] = Field(None, max_length=32) bis: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,40 +1,50 @@
"""BAN YARO — Hundefreundliche Orte""" """BAN YARO — Hundefreundliche Orte"""
import math
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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, require_owner from auth import get_current_user
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'} TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'}
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PlaceCreate(BaseModel): class PlaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200) name: str
typ: str = Field(..., max_length=50) typ: str
lat: float lat: float
lon: float lon: float
adresse: Optional[str] = Field(None, max_length=300) adresse: Optional[str] = None
website: Optional[str] = Field(None, max_length=500) website: Optional[str] = None
telefon: Optional[str] = Field(None, max_length=30) telefon: Optional[str] = None
hund_rein: Optional[bool] = None hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None
class PlaceUpdate(BaseModel): class PlaceUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200) name: Optional[str] = None
typ: Optional[str] = Field(None, max_length=50) typ: Optional[str] = None
lat: Optional[float]= None lat: Optional[float]= None
lon: Optional[float]= None lon: Optional[float]= None
adresse: Optional[str] = Field(None, max_length=300) adresse: Optional[str] = None
website: Optional[str] = Field(None, max_length=500) website: Optional[str] = None
telefon: Optional[str] = Field(None, max_length=30) telefon: Optional[str] = None
hund_rein: Optional[bool] = None hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None
@ -69,7 +79,7 @@ async def list_places(
result = [_row_to_dict(r) for r in rows] result = [_row_to_dict(r) for r in rows]
if lat is not None and lon is not None: if lat is not None and lon is not None:
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius]
return result return result
@ -121,10 +131,11 @@ async def get_place(place_id: int):
@router.patch("/{place_id}") @router.patch("/{place_id}")
async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)): async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
row = require_owner( row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), if not row:
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." raise HTTPException(404, "Ort nicht gefunden.")
) if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
updates = data.model_dump(exclude_none=True) updates = data.model_dump(exclude_none=True)
if not updates: if not updates:
@ -149,8 +160,9 @@ async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_curren
@router.delete("/{place_id}", status_code=204) @router.delete("/{place_id}", status_code=204)
async def delete_place(place_id: int, user=Depends(get_current_user)): async def delete_place(place_id: int, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
require_owner( row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), if not row:
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." raise HTTPException(404, "Ort nicht gefunden.")
) if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) conn.execute("DELETE FROM places WHERE id = ?", (place_id,))

View file

@ -1,17 +1,30 @@
"""BAN YARO — Playdate-Matching""" """BAN YARO — Playdate-Matching"""
import math
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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 from auth import get_current_user
from math_utils import haversine_km
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Haversine
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
def _calc_alter(geburtstag: Optional[str]) -> Optional[str]: def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag: if not geburtstag:
@ -40,18 +53,18 @@ class ListingUpsert(BaseModel):
dog_id: int dog_id: int
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
radius_km: int = 10 radius_km: int = 10
beschreibung: Optional[str] = Field(None, max_length=2000) beschreibung: Optional[str] = None
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
to_dog_id: int to_dog_id: int
nachricht: Optional[str] = Field(None, max_length=2000) nachricht: Optional[str] = None
class RequestPatch(BaseModel): class RequestPatch(BaseModel):
status: str = Field(..., max_length=30) # accepted | declined status: str # accepted | declined
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -94,7 +107,7 @@ async def nearby(lat: float, lon: float, radius: int = 10,
result = [] result = []
for r in rows: for r in rows:
dist = haversine_km(lat, lon, r["lat"], r["lon"]) dist = _haversine(lat, lon, r["lat"], r["lon"])
if dist <= radius: if dist <= radius:
result.append({ result.append({
"listing_id": r["listing_id"], "listing_id": r["listing_id"],

View file

@ -1,33 +1,45 @@
"""BAN YARO — Giftköder-Alarm Routes""" """BAN YARO — Giftköder-Alarm Routes"""
import os, uuid import os, uuid, math
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel, Field 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 from auth import get_current_user
from routes.push import send_push_nearby from routes.push import send_push_nearby
from media_utils import convert_media from media_utils import convert_media
from ratelimit import check as rl_check from ratelimit import check as rl_check
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PoisonCreate(BaseModel): class PoisonCreate(BaseModel):
lat: float lat: float
lon: float lon: float
beschreibung: Optional[str] = Field(None, max_length=2000) beschreibung: Optional[str] = None
typ: str = Field("unbekannt", max_length=50) typ: str = "unbekannt"
class PoisonResolve(BaseModel): class PoisonResolve(BaseModel):
grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -50,7 +62,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000):
results = [] results = []
for r in rows: for r in rows:
entry = dict(r) entry = dict(r)
dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist <= radius: if dist <= radius:
entry["distanz_m"] = round(dist) entry["distanz_m"] = round(dist)
results.append(entry) results.append(entry)

View file

@ -7,7 +7,7 @@ import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from auth import get_current_user from auth import get_current_user
from database import db from database import db
@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
real_name: Optional[str] = Field(None, max_length=100) real_name: Optional[str] = None
bio: Optional[str] = Field(None, max_length=300) bio: Optional[str] = None
wohnort: Optional[str] = Field(None, max_length=60) wohnort: Optional[str] = None
erfahrung: Optional[str] = Field(None, max_length=30) erfahrung: Optional[str] = None
social_link: Optional[str] = Field(None, max_length=120) social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = Field(None, max_length=30) profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = Field(None, max_length=20) preferred_theme: Optional[str] = None
billing_address: Optional[str] = Field(None, max_length=500) billing_address: Optional[str] = None
geburtstag: Optional[str] = Field(None, max_length=10) geburtstag: Optional[str] = None
def _load_user(user_id: int) -> dict: def _load_user(user_id: int) -> dict:
@ -61,7 +61,12 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.") raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.")
if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"): if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"):
raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.") raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.")
# Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt. if "bio" in fields and len(fields["bio"]) > 300:
raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.")
if "wohnort" in fields and len(fields["wohnort"]) > 60:
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
if "social_link" in fields and len(fields["social_link"]) > 120:
raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.")
if "geburtstag" in fields and fields["geburtstag"]: if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]): if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).") raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).")

View file

@ -4,7 +4,7 @@ import os
import json import json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
@ -33,7 +33,7 @@ async def get_vapid_key():
# POST /api/push/subscribe — Subscription speichern # POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PushSubscription(BaseModel): class PushSubscription(BaseModel):
endpoint: str = Field(..., max_length=2000) endpoint: str
keys: dict # { p256dh, auth } keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None expirationTime: Optional[int] = None

View file

@ -1,7 +1,7 @@
"""BAN YARO — Bewertungssystem (Ratings)""" """BAN YARO — Bewertungssystem (Ratings)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -23,10 +23,10 @@ TABLE_MAP = {
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RatingCreate(BaseModel): class RatingCreate(BaseModel):
target_type: str = Field(..., max_length=50) target_type: str
target_id: int target_id: int
stars: int stars: int
kommentar: Optional[str] = Field(None, max_length=5000) kommentar: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -49,27 +49,12 @@ async def list_recalls(q: str = ""):
# Interne Hilfsfunktion: RASFF API abfragen # Interne Hilfsfunktion: RASFF API abfragen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def fetch_rasff_recalls() -> list[dict]: async def fetch_rasff_recalls() -> list[dict]:
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück. """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
Hinweis: Die EU hat die API mehrfach umgezogen wenn der Endpoint
404 oder andere persistent fehler liefert, geben wir [] zurück und
loggen nur als Warning (nicht Error), damit das Error-Digest nicht
täglich spammt.
"""
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(RASFF_URL, params=RASFF_PARAMS) resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except httpx.HTTPStatusError as e:
if e.response.status_code in (404, 410, 503):
# API umgezogen oder temporär unten — Warning, kein Error
logger.warning(
f"RASFF API liefert {e.response.status_code} (Endpoint vermutlich umgezogen) — überspringe."
)
else:
logger.error(f"RASFF API-HTTP-Fehler: {e}")
return []
except Exception as e: except Exception as e:
logger.error(f"RASFF API-Fehler: {e}") logger.error(f"RASFF API-Fehler: {e}")
return [] return []

View file

@ -1,11 +1,11 @@
"""BAN YARO — Gassi-Routen""" """BAN YARO — Gassi-Routen"""
import datetime as _dt import datetime as _dt
import json, os, uuid import json, math, os, uuid
import httpx import httpx
import polyline as _polyline import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -13,7 +13,6 @@ from routes.achievements import update_streak, check_and_award
from timeutils import safe_client_time from timeutils import safe_client_time
from media_utils import convert_media from media_utils import convert_media
from routes.push import send_push_to_user from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
router = APIRouter() router = APIRouter()
@ -28,6 +27,16 @@ def _check_speed(distanz_km, dauer_min) -> bool:
return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -37,29 +46,29 @@ class GPSPoint(BaseModel):
alt: Optional[float] = None alt: Optional[float] = None
class RouteCreate(BaseModel): class RouteCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200) name: str
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
gps_track: List[GPSPoint] gps_track: List[GPSPoint]
distanz_km: Optional[float] = None distanz_km: Optional[float] = None
dauer_min: Optional[int] = None dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix untergrund: Optional[str] = None # wald | asphalt | wiese | mix
schatten: Optional[bool] = None schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = Field(None, max_length=64) client_time: Optional[str] = None
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel): class RouteUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200) name: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
schwierigkeit: Optional[str] = Field(None, max_length=30) schwierigkeit: Optional[str] = None
untergrund: Optional[str] = Field(None, max_length=50) untergrund: Optional[str] = None
schatten: Optional[bool] = None schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = None is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) hunde_tauglichkeit: Optional[str] = None
class RouteDogs(BaseModel): class RouteDogs(BaseModel):
dog_ids: List[int] dog_ids: List[int]
@ -128,7 +137,7 @@ async def list_routes(
if lat is not None and lon is not None: if lat is not None and lon is not None:
result = [ result = [
r for r in result r for r in result
if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius
] ]
user_id = user['id'] if user else None user_id = user['id'] if user else None
@ -420,7 +429,10 @@ async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_us
new_km = 0.0 new_km = 0.0
for i in range(1, len(new_track)): for i in range(1, len(new_track)):
p1, p2 = new_track[i-1], new_track[i] p1, p2 = new_track[i-1], new_track[i]
new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon']) dlat = math.radians(p2['lat'] - p1['lat'])
dlon = math.radians(p2['lon'] - p1['lon'])
a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2
new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
new_km = round(new_km, 2) new_km = round(new_km, 2)
# Dauer proportional schätzen (Original-Pace) # Dauer proportional schätzen (Original-Pace)
@ -553,7 +565,7 @@ async def add_route_photo(
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller # POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RouteFeedback(BaseModel): class RouteFeedback(BaseModel):
text: str = Field(..., min_length=5, max_length=2000) text: str
@router.post("/{route_id}/feedback", status_code=201) @router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)): async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):

View file

@ -1,23 +1,33 @@
"""BAN YARO — Service-Angebote (Sitting & Walks Matching)""" """BAN YARO — Service-Angebote (Sitting & Walks Matching)"""
import math
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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 from auth import get_current_user
from math_utils import haversine_km
router = APIRouter() router = APIRouter()
ALLOWED_TYPES = {'sitting', 'walks'} ALLOWED_TYPES = {'sitting', 'walks'}
def _haversine(lat1, lon1, lat2, lon2):
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ServiceCreate(BaseModel): class ServiceCreate(BaseModel):
type: str = Field(..., max_length=30) type: str
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
preis_pro_tag: Optional[float] = None preis_pro_tag: Optional[float] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
@ -50,7 +60,7 @@ async def list_services(
for r in rows: for r in rows:
d = dict(r) d = dict(r)
if lat is not None and lon is not None and d['lat'] and d['lon']: if lat is not None and lon is not None and d['lat'] and d['lon']:
dist = haversine_km(lat, lon, d['lat'], d['lon']) dist = _haversine(lat, lon, d['lat'], d['lon'])
if dist > radius: if dist > radius:
continue continue
d['distanz_km'] = round(dist, 1) d['distanz_km'] = round(dist, 1)

View file

@ -2,7 +2,7 @@
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -14,7 +14,7 @@ share_router = APIRouter()
class ShareInvite(BaseModel): class ShareInvite(BaseModel):
role: str = Field("editor", max_length=20) # viewer | editor role: str = "editor" # viewer | editor
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,23 +1,32 @@
"""BAN YARO — Hundesitting""" """BAN YARO — Hundesitting"""
import json import json
import math
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'}
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class SitterCreate(BaseModel): class SitterCreate(BaseModel):
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
preis_pro_tag: float = 0 preis_pro_tag: float = 0
max_hunde: int = 1 max_hunde: int = 1
lat: Optional[float] = None lat: Optional[float] = None
@ -26,7 +35,7 @@ class SitterCreate(BaseModel):
services: List[str] = [] services: List[str] = []
class SitterUpdate(BaseModel): class SitterUpdate(BaseModel):
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
preis_pro_tag: Optional[float] = None preis_pro_tag: Optional[float] = None
max_hunde: Optional[int] = None max_hunde: Optional[int] = None
lat: Optional[float] = None lat: Optional[float] = None
@ -38,12 +47,12 @@ class SitterUpdate(BaseModel):
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
sitter_id: int sitter_id: int
dog_ids: List[int] = [] dog_ids: List[int] = []
von: str = Field(..., max_length=32) # YYYY-MM-DD von: str # YYYY-MM-DD
bis: str = Field(..., max_length=32) bis: str
nachricht: Optional[str] = Field(None, max_length=2000) nachricht: Optional[str] = None
class RequestUpdate(BaseModel): class RequestUpdate(BaseModel):
status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen status: str # angenommen | abgelehnt | abgebrochen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -71,7 +80,7 @@ async def list_sitters(
if service and service not in d['services']: if service and service not in d['services']:
continue continue
if lat is not None and lon is not None and d['lat'] and d['lon']: if lat is not None and lon is not None and d['lat'] and d['lon']:
dist = haversine_m(lat, lon, d['lat'], d['lon']) dist = _haversine(lat, lon, d['lat'], d['lon'])
if dist > radius: if dist > radius:
continue continue
d['distanz_m'] = round(dist) d['distanz_m'] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)""" """BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -11,7 +11,7 @@ router = APIRouter()
class AccessCreate(BaseModel): class AccessCreate(BaseModel):
dog_id: int dog_id: int
sitter_id: int sitter_id: int
valid_until: str = Field(..., max_length=32) # 'YYYY-MM-DD' valid_until: str # 'YYYY-MM-DD'
@router.post("", status_code=201) @router.post("", status_code=201)

View file

@ -9,7 +9,7 @@ import random
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from auth import get_current_user, require_social_media from auth import get_current_user, require_social_media
from database import db from database import db
@ -849,24 +849,24 @@ Antworte NUR mit einem JSON-Objekt:
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
platform: str = Field("both", max_length=30) platform: str = "both"
format: str = Field("post", max_length=30) format: str = "post"
topic: str = Field(..., min_length=2, max_length=500) topic: str
breed_id: Optional[int] = None breed_id: Optional[int] = None
class EvaluateRequest(BaseModel): class EvaluateRequest(BaseModel):
platform: str = Field("instagram", max_length=30) platform: str = "instagram"
format: str = Field("post", max_length=30) format: str = "post"
draft: str = Field(..., min_length=1, max_length=10000) draft: str
class StatusUpdate(BaseModel): class StatusUpdate(BaseModel):
status: Optional[str] = Field(None, max_length=50) status: Optional[str] = None
scheduled_at: Optional[str] = Field(None, max_length=64) scheduled_at: Optional[str] = None
published_at: Optional[str] = Field(None, max_length=64) published_at: Optional[str] = None
notes: Optional[str] = Field(None, max_length=5000) notes: Optional[str] = None
post_url: Optional[str] = Field(None, max_length=500) post_url: Optional[str] = None
def _used_topics(limit: int = 30) -> str: def _used_topics(limit: int = 30) -> str:

View file

@ -2,7 +2,7 @@
import math import math
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field 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 from auth import get_current_user
@ -11,20 +11,20 @@ router = APIRouter()
class TierarztCreate(BaseModel): class TierarztCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200) name: str
strasse: Optional[str] = Field(None, max_length=300) strasse: Optional[str] = None
plz: Optional[str] = Field(None, max_length=20) plz: Optional[str] = None
ort: Optional[str] = Field(None, max_length=200) ort: Optional[str] = None
telefon: Optional[str] = Field(None, max_length=30) telefon: Optional[str] = None
notfall_telefon: Optional[str] = Field(None, max_length=30) notfall_telefon: Optional[str] = None
email: Optional[str] = Field(None, max_length=254) email: Optional[str] = None
website: Optional[str] = Field(None, max_length=500) website: Optional[str] = None
notizen: Optional[str] = Field(None, max_length=5000) notizen: Optional[str] = None
ist_notfallpraxis: bool = False ist_notfallpraxis: bool = False
opening_hours: Optional[str] = Field(None, max_length=500) opening_hours: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
osm_id: Optional[str] = Field(None, max_length=100) osm_id: Optional[str] = None
class BewertungCreate(BaseModel): class BewertungCreate(BaseModel):
@ -32,25 +32,25 @@ class BewertungCreate(BaseModel):
wartezeit: Optional[int] = None wartezeit: Optional[int] = None
freundlichkeit: Optional[int] = None freundlichkeit: Optional[int] = None
kompetenz: Optional[int] = None kompetenz: Optional[int] = None
text: Optional[str] = Field(None, max_length=5000) text: Optional[str] = None
class TierarztUpdate(BaseModel): class TierarztUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200) name: Optional[str] = None
strasse: Optional[str] = Field(None, max_length=300) strasse: Optional[str] = None
plz: Optional[str] = Field(None, max_length=20) plz: Optional[str] = None
ort: Optional[str] = Field(None, max_length=200) ort: Optional[str] = None
telefon: Optional[str] = Field(None, max_length=30) telefon: Optional[str] = None
notfall_telefon: Optional[str] = Field(None, max_length=30) notfall_telefon: Optional[str] = None
email: Optional[str] = Field(None, max_length=254) email: Optional[str] = None
website: Optional[str] = Field(None, max_length=500) website: Optional[str] = None
notizen: Optional[str] = Field(None, max_length=5000) notizen: Optional[str] = None
ist_notfallpraxis: Optional[bool] = None ist_notfallpraxis: Optional[bool] = None
aktiv: Optional[bool] = None aktiv: Optional[bool] = None
opening_hours: Optional[str] = Field(None, max_length=500) opening_hours: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
osm_id: Optional[str] = Field(None, max_length=100) osm_id: Optional[str] = None
def _fmt_opening_hours(raw: str | None) -> str | None: def _fmt_opening_hours(raw: str | None) -> str | None:

View file

@ -1,7 +1,7 @@
"""BAN YARO — Übungs- & Trainingsfortschritt""" """BAN YARO — Übungs- & Trainingsfortschritt"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
import datetime import datetime
import ki import ki
@ -61,9 +61,9 @@ async def get_exercises():
# Admin: Übung bearbeiten (beschreibung / schritte / tipp) # Admin: Übung bearbeiten (beschreibung / schritte / tipp)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ExerciseUpdate(BaseModel): class ExerciseUpdate(BaseModel):
beschreibung: Optional[str] = Field(None, max_length=10000) beschreibung: Optional[str] = None
schritte: Optional[str] = Field(None, max_length=10000) # JSON-String: '["Schritt 1", ...]' schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = Field(None, max_length=5000) tipp: Optional[str] = None
@router.put("/exercises/{exercise_id}") @router.put("/exercises/{exercise_id}")
async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)): async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)):
@ -93,8 +93,8 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# Übungs-Status # Übungs-Status
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ProgressUpdate(BaseModel): class ProgressUpdate(BaseModel):
exercise_id: str = Field(..., max_length=200) exercise_id: str
status: Optional[str] = Field(None, max_length=50) status: Optional[str] = None
dog_id: Optional[int] = None dog_id: Optional[int] = None
@router.get("/progress") @router.get("/progress")
@ -137,7 +137,7 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
# Trainingsplan-Checkboxen # Trainingsplan-Checkboxen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PlanProgress(BaseModel): class PlanProgress(BaseModel):
item_key: str = Field(..., max_length=200) item_key: str
checked: bool checked: bool
dog_id: Optional[int] = None dog_id: Optional[int] = None
@ -327,14 +327,14 @@ def _check_badges(conn, user_id: int, dog_name: str) -> list:
class SessionCreate(BaseModel): class SessionCreate(BaseModel):
dog_id: int dog_id: int
exercise_id: str = Field(..., max_length=200) exercise_id: str
exercise_name: str = Field(..., max_length=200) exercise_name: str
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
wiederholungen: int = 1 wiederholungen: int = 1
erfolgsquote: int = 50 erfolgsquote: int = 50
hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50) hund_stimmung: Optional[str] = "aufmerksam"
zufriedenheit: Optional[int] = 3 zufriedenheit: Optional[int] = 3
notiz: Optional[str] = Field(None, max_length=2000) notiz: Optional[str] = None
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll

View file

@ -1,43 +1,55 @@
"""BAN YARO — Gassi-Treffen""" """BAN YARO — Gassi-Treffen"""
import os, uuid import math, os, uuid
import httpx import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from routes.push import send_push_to_user from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
router = APIRouter() router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _haversine_km(lat1, lon1, lat2, lon2):
return _haversine(lat1, lon1, lat2, lon2) / 1000
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class WalkCreate(BaseModel): class WalkCreate(BaseModel):
titel: str = Field(..., min_length=1, max_length=200) titel: str
datum: str = Field(..., max_length=32) # YYYY-MM-DD datum: str # YYYY-MM-DD
uhrzeit: str = Field(..., max_length=20) # HH:MM uhrzeit: str # HH:MM
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
max_teilnehmer: int = 10 max_teilnehmer: int = 10
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
class WalkUpdate(BaseModel): class WalkUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32) datum: Optional[str] = None
uhrzeit: Optional[str] = Field(None, max_length=20) uhrzeit: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = Field(None, max_length=300) ort_name: Optional[str] = None
max_teilnehmer: Optional[int] = None max_teilnehmer: Optional[int] = None
beschreibung: Optional[str] = Field(None, max_length=5000) beschreibung: Optional[str] = None
class JoinRequest(BaseModel): class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
@ -46,7 +58,7 @@ class InviteRequest(BaseModel):
friend_id: int friend_id: int
class RsvpRequest(BaseModel): class RsvpRequest(BaseModel):
status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no' status: str # 'yes' | 'maybe' | 'no'
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -79,7 +91,7 @@ async def list_walks(
# Umkreis-Filter # Umkreis-Filter
if lat is not None and lon is not None: if lat is not None and lon is not None:
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius]
return result return result
@ -119,7 +131,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
"SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL",
).fetchall() ).fetchall()
for p in places: for p in places:
km = haversine_km(lat, lon, p["lat"], p["lon"]) km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5: if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place", results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"], "lat": p["lat"], "lon": p["lon"],
@ -130,7 +142,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall() ).fetchall()
for p in osm: for p in osm:
km = haversine_km(lat, lon, p["lat"], p["lon"]) km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2: if km <= 2:
results.append({"name": p["name"], "type": p["type"], results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"], "lat": p["lat"], "lon": p["lon"],
@ -158,7 +170,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
elon = el.get("lon") or el.get("center", {}).get("lon") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat is None or elon is None: if elat is None or elon is None:
continue continue
km = haversine_km(lat, lon, elat, elon) km = _haversine_km(lat, lon, elat, elon)
if km <= 1: if km <= 1:
results.append({"name": name, "type": "osm", results.append({"name": name, "type": "osm",
"lat": elat, "lon": elon, "lat": elat, "lon": elon,

View file

@ -6,7 +6,7 @@ import time
import logging import logging
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip from ratelimit import check as rl_check, block_ip
@ -36,9 +36,9 @@ async def honeypot(request: Request):
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class BerichtCreate(BaseModel): class BerichtCreate(BaseModel):
rasse: str = Field(..., max_length=100) rasse: str
titel: str = Field(..., min_length=3, max_length=200) titel: str
text: str = Field(..., min_length=10, max_length=10000) text: str
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -411,8 +411,8 @@ async def list_submissions(user=Depends(get_current_user)):
# PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen # PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReviewModel(BaseModel): class ReviewModel(BaseModel):
action: str = Field(..., max_length=30) # "approve" | "reject" action: str # "approve" | "reject"
reject_reason: str = Field("", max_length=2000) reject_reason: str = ""
@router.patch("/foto-submissions/{sub_id}") @router.patch("/foto-submissions/{sub_id}")
@ -575,19 +575,19 @@ async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)):
# Schemas für Interesse und Züchter # Schemas für Interesse und Züchter
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class InteresseCreate(BaseModel): class InteresseCreate(BaseModel):
typ: str = Field(..., max_length=30) # "hat" oder "will" typ: str # "hat" oder "will"
class ZuchterCreate(BaseModel): class ZuchterCreate(BaseModel):
rasse_slug: str = Field(..., max_length=100) rasse_slug: str
name: str = Field(..., min_length=1, max_length=200) name: str
zwingername: str = Field("", max_length=200) zwingername: str = ""
ort: str = Field("", max_length=200) ort: str = ""
plz: str = Field("", max_length=20) plz: str = ""
bundesland: str = Field("", max_length=100) bundesland: str = ""
vdh_mitglied: int = 0 vdh_mitglied: int = 0
website: str = Field("", max_length=500) website: str = ""
telefon: str = Field("", max_length=30) telefon: str = ""
beschreibung: str = Field("", max_length=10000) beschreibung: str = ""
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
@ -134,108 +134,108 @@ def _ik_rating(ik: float) -> str:
# Pydantic-Schemas # Pydantic-Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class HundCreate(BaseModel): class HundCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200) name: str
rufname: Optional[str] = Field(None, max_length=80) rufname: Optional[str] = None
geschlecht: str = Field(..., max_length=20) # maennlich|weiblich geschlecht: str # maennlich|weiblich
geburtsdatum: Optional[str] = Field(None, max_length=32) geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = Field(None, max_length=32) sterbedatum: Optional[str] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = Field(None, max_length=50) taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = Field(None, max_length=100) zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = Field(None, max_length=100) farbe: Optional[str] = None
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
zuechter_name: Optional[str] = Field(None, max_length=200) zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = Field(None, max_length=200) eigentuemer_name: Optional[str] = None
is_public: int = 1 is_public: int = 1
notiz: Optional[str] = Field(None, max_length=5000) notiz: Optional[str] = None
foto_url: Optional[str] = Field(None, max_length=500) foto_url: Optional[str] = None
class HundUpdate(BaseModel): class HundUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200) name: Optional[str] = None
rufname: Optional[str] = Field(None, max_length=80) rufname: Optional[str] = None
geschlecht: Optional[str] = Field(None, max_length=20) geschlecht: Optional[str] = None
geburtsdatum: Optional[str] = Field(None, max_length=32) geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = Field(None, max_length=32) sterbedatum: Optional[str] = None
chip_nr: Optional[str] = Field(None, max_length=50) chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = Field(None, max_length=50) taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = Field(None, max_length=100) zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = Field(None, max_length=100) farbe: Optional[str] = None
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
zuechter_name: Optional[str] = Field(None, max_length=200) zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = Field(None, max_length=200) eigentuemer_name: Optional[str] = None
is_public: Optional[int] = None is_public: Optional[int] = None
notiz: Optional[str] = Field(None, max_length=5000) notiz: Optional[str] = None
foto_url: Optional[str] = Field(None, max_length=500) foto_url: Optional[str] = None
class HealthTestCreate(BaseModel): class HealthTestCreate(BaseModel):
test_typ: str = Field(..., max_length=50) # HD|ED|OCD|augen|herz|patella|ZTP|custom test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom
test_name: Optional[str] = Field(None, max_length=200) test_name: Optional[str] = None
ergebnis: Optional[str] = Field(None, max_length=500) ergebnis: Optional[str] = None
untersuch_am: Optional[str] = Field(None, max_length=32) untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = Field(None, max_length=32) gueltig_bis: Optional[str] = None
untersucher: Optional[str] = Field(None, max_length=200) untersucher: Optional[str] = None
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
zertifikat_nr: Optional[str] = Field(None, max_length=100) zertifikat_nr: Optional[str] = None
is_public: int = 1 is_public: int = 1
class HealthTestUpdate(BaseModel): class HealthTestUpdate(BaseModel):
test_typ: Optional[str] = Field(None, max_length=50) test_typ: Optional[str] = None
test_name: Optional[str] = Field(None, max_length=200) test_name: Optional[str] = None
ergebnis: Optional[str] = Field(None, max_length=500) ergebnis: Optional[str] = None
untersuch_am: Optional[str] = Field(None, max_length=32) untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = Field(None, max_length=32) gueltig_bis: Optional[str] = None
untersucher: Optional[str] = Field(None, max_length=200) untersucher: Optional[str] = None
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
zertifikat_nr: Optional[str] = Field(None, max_length=100) zertifikat_nr: Optional[str] = None
is_public: Optional[int] = None is_public: Optional[int] = None
class GeneticTestCreate(BaseModel): class GeneticTestCreate(BaseModel):
marker_name: str = Field(..., max_length=100) # MDR1|PRA-prcd|DM|vWD|HUU etc. marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc.
marker_kategorie: Optional[str] = Field(None, max_length=50) # krankheit|farbe|eigenschaft marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft
genotyp: Optional[str] = Field(None, max_length=20) # +/+|+/-|-/- genotyp: Optional[str] = None # +/+|+/-|-/-
ergebnis_klasse: Optional[str] = Field(None, max_length=50) # clear|carrier|affected ergebnis_klasse: Optional[str] = None # clear|carrier|affected
getestet_am: Optional[str] = Field(None, max_length=32) getestet_am: Optional[str] = None
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
zertifikat_nr: Optional[str] = Field(None, max_length=100) zertifikat_nr: Optional[str] = None
is_public: int = 1 is_public: int = 1
class GeneticTestUpdate(BaseModel): class GeneticTestUpdate(BaseModel):
marker_name: Optional[str] = Field(None, max_length=100) marker_name: Optional[str] = None
marker_kategorie: Optional[str] = Field(None, max_length=50) marker_kategorie: Optional[str] = None
genotyp: Optional[str] = Field(None, max_length=20) genotyp: Optional[str] = None
ergebnis_klasse: Optional[str] = Field(None, max_length=50) ergebnis_klasse: Optional[str] = None
getestet_am: Optional[str] = Field(None, max_length=32) getestet_am: Optional[str] = None
labor: Optional[str] = Field(None, max_length=200) labor: Optional[str] = None
zertifikat_nr: Optional[str] = Field(None, max_length=100) zertifikat_nr: Optional[str] = None
is_public: Optional[int] = None is_public: Optional[int] = None
class TitelCreate(BaseModel): class TitelCreate(BaseModel):
titel_typ: str = Field(..., max_length=50) # ausstellung|arbeit|sport|zucht|champion|custom titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom
titel_name: str = Field(..., min_length=1, max_length=200) titel_name: str
verliehen_am: Optional[str] = Field(None, max_length=32) verliehen_am: Optional[str] = None
ort: Optional[str] = Field(None, max_length=200) ort: Optional[str] = None
richter: Optional[str] = Field(None, max_length=200) richter: Optional[str] = None
ausstellung: Optional[str] = Field(None, max_length=200) ausstellung: Optional[str] = None
formwert: Optional[str] = Field(None, max_length=100) formwert: Optional[str] = None
is_public: int = 1 is_public: int = 1
class TitelUpdate(BaseModel): class TitelUpdate(BaseModel):
titel_typ: Optional[str] = Field(None, max_length=50) titel_typ: Optional[str] = None
titel_name: Optional[str] = Field(None, max_length=200) titel_name: Optional[str] = None
verliehen_am: Optional[str] = Field(None, max_length=32) verliehen_am: Optional[str] = None
ort: Optional[str] = Field(None, max_length=200) ort: Optional[str] = None
richter: Optional[str] = Field(None, max_length=200) richter: Optional[str] = None
ausstellung: Optional[str] = Field(None, max_length=200) ausstellung: Optional[str] = None
formwert: Optional[str] = Field(None, max_length=100) formwert: Optional[str] = None
is_public: Optional[int] = None is_public: Optional[int] = None

View file

@ -3,7 +3,7 @@
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, Literal from typing import Optional, Literal
from database import db from database import db
@ -41,7 +41,7 @@ class PaarungAnalyseBody(BaseModel):
vater_id: int vater_id: int
mutter_id: int mutter_id: int
ik_prozent: Optional[float] = None ik_prozent: Optional[float] = None
welfare_level: Optional[str] = Field(None, max_length=50) welfare_level: Optional[str] = None
class HundBeschreibungBody(BaseModel): class HundBeschreibungBody(BaseModel):

View file

@ -46,14 +46,6 @@ def start():
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True, coalesce=True,
) )
_scheduler.add_job(
_job_purge_jwt_blacklist,
CronTrigger(hour=3, minute=30), # täglich 03:30 Uhr, nach poison_archive
id="purge_jwt_blacklist",
replace_existing=True,
misfire_grace_time=3600,
coalesce=True,
)
_scheduler.add_job( _scheduler.add_job(
_job_weather_alert, _job_weather_alert,
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr
@ -1840,13 +1832,11 @@ async def _job_anniversary_reminders():
logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
with db() as conn: with db() as conn:
# diary hat keinen user_id — User kommt über dogs.user_id
entries = conn.execute(""" entries = conn.execute("""
SELECT d.id, d.titel, d.datum, dogs.user_id, d.dog_id, SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
(SELECT dm.url FROM diary_media dm (SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
FROM diary d FROM diary d
JOIN dogs ON dogs.id = d.dog_id
WHERE strftime('%m-%d', d.datum) = ? WHERE strftime('%m-%d', d.datum) = ?
AND d.datum < date('now') AND d.datum < date('now')
AND d.titel IS NOT NULL AND d.titel IS NOT NULL
@ -2241,16 +2231,3 @@ async def _job_error_digest():
except Exception as e: except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {e}") logger.error(f"Error-Digest: Mail-Fehler: {e}")
_log_job("error_digest", "error", str(e)) _log_job("error_digest", "error", str(e))
def _job_purge_jwt_blacklist():
"""Räumt abgelaufene Einträge aus jwt_blacklist auf — sonst wächst die
Tabelle monoton mit jedem Logout. Läuft täglich 03:30."""
try:
from auth import _purge_expired_jwt
deleted = _purge_expired_jwt()
logger.info(f"jwt_blacklist: {deleted} abgelaufene Einträge gelöscht.")
_log_job("purge_jwt_blacklist", "ok", f"{deleted} entries deleted")
except Exception as e:
logger.exception(f"jwt_blacklist purge fehlgeschlagen: {e}")
_log_job("purge_jwt_blacklist", "error", str(e))

View file

@ -235,45 +235,6 @@
color: var(--c-primary); color: var(--c-primary);
} }
/* ----- .by-tabs Modifier-Varianten ----------------------------- */
/* Grid-Layout (Admin/Health/Übungen — Desktop oft 2-3 Spalten) */
.by-tabs.grid {
display: grid;
grid-template-columns: repeat(var(--tab-cols, 4), minmax(0, 1fr));
overflow: visible;
gap: var(--space-2);
}
/* Flex-Wrap (Zuchthunde — Buttons brechen um statt zu scrollen) */
.by-tabs.wrap {
flex-wrap: wrap;
overflow-x: visible;
}
/* Separated — eigener Hintergrund + Border (Sitting) */
.by-tabs.separated {
padding: var(--space-3) var(--space-4) var(--space-2);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
/* Sticky (Admin Desktop vertikal) — nur ab 1024px */
@media (min-width: 1024px) {
.by-tabs.sticky {
position: sticky;
top: var(--space-3);
flex-direction: column;
width: 190px;
gap: var(--space-1);
}
.by-tabs.sticky .by-tab {
justify-content: flex-start;
text-align: left;
padding: var(--space-2) var(--space-3);
}
}
/* ------------------------------------------------------------ /* ------------------------------------------------------------
4. BY-SECTION-LABEL + BY-TOOLBAR weitere gemeinsame Elemente 4. BY-SECTION-LABEL + BY-TOOLBAR weitere gemeinsame Elemente
------------------------------------------------------------ */ ------------------------------------------------------------ */
@ -8944,44 +8905,3 @@ svg.empty-state-icon {
.offline-status-row .osr-text { flex: 1; min-width: 0; } .offline-status-row .osr-text { flex: 1; min-width: 0; }
.offline-status-row .osr-title { font-weight: 600; } .offline-status-row .osr-title { font-weight: 600; }
.offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } .offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; }
/* ============================================================
.map-list-toggle vereinheitlichter Karten/Listen-Umschalter
Verwendet von walks.js, events.js, routes.js, etc.
<div class="map-list-toggle">
<button class="active" data-view="list">Liste</button>
<button data-view="map">Karte</button>
</div>
============================================================ */
.map-list-toggle {
display: flex;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--c-surface);
}
.map-list-toggle button {
flex: 1;
height: 44px;
border: none;
background: transparent;
color: var(--c-text-secondary);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
transition: background 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.map-list-toggle button.active {
background: var(--c-primary);
color: #fff;
}
.map-list-toggle button:not(.active):hover {
background: var(--c-surface-2);
color: var(--c-text);
}

View file

@ -34,7 +34,7 @@
/* Text — Warmbraun aus dem Halsband */ /* Text — Warmbraun aus dem Halsband */
--c-text: #2A1F14; --c-text: #2A1F14;
--c-text-secondary: #7A6A58; --c-text-secondary: #7A6A58;
--c-text-muted: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */ --c-text-muted: #B0A090;
--c-text-inverse: #FAF7F2; --c-text-inverse: #FAF7F2;
/* Funktionsfarben */ /* Funktionsfarben */
@ -179,7 +179,7 @@
--c-text: #F0EAE0; --c-text: #F0EAE0;
--c-text-secondary: #C0B0A0; --c-text-secondary: #C0B0A0;
--c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */ --c-text-muted: #806A58;
--c-text-inverse: #2A1F14; --c-text-inverse: #2A1F14;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);

View file

@ -86,8 +86,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 40px;
height: 44px; height: 40px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--c-text-secondary); color: var(--c-text-secondary);
cursor: pointer; cursor: pointer;
@ -99,8 +99,8 @@
/* Hamburger-Button (nur Mobile) */ /* Hamburger-Button (nur Mobile) */
.header-menu-btn { .header-menu-btn {
width: 44px; width: 40px;
height: 44px; height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -1,328 +0,0 @@
/* ============================================================
BAN YARO Listen-Komponenten
Wiederverwendbare Klassen für Seiten mit Listen+Detail-Pattern:
Notes, Expenses, Health, Diary, Behavior-Log, ...
Verwendung:
<div class="list-shell">
<div class="list-filter-bar">...</div>
<div class="list-group-header">Mai 2026</div>
<div class="list-item-card list-item-card--clickable" data-id="...">
<div class="list-item-meta-badge" style="--meta-color:#f97316">🍖</div>
<div class="list-item-body">
<div class="list-item-title">Titel</div>
<div class="list-item-text">Vorschau-Text</div>
<div class="list-item-meta-row">
<span>10:30</span> · <span>📍 Berlin</span>
</div>
</div>
<div class="list-item-amount">25,50 </div>
</div>
</div>
============================================================ */
/* ------------------------------------------------------------
Shell + Header
------------------------------------------------------------ */
.list-shell {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.list-filter-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
flex-wrap: wrap;
align-items: center;
}
.list-search-wrap {
flex: 1;
min-width: 200px;
position: relative;
display: flex;
align-items: center;
}
.list-search-wrap > input { width: 100%; }
/* ------------------------------------------------------------
Group-Header (Monat / Datums-Gruppe)
------------------------------------------------------------ */
.list-group-header {
font-size: var(--text-xs);
font-weight: 600;
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-3) var(--space-4) var(--space-1);
margin-top: var(--space-2);
}
/* ------------------------------------------------------------
Item-Card (universelle Listen-Karte)
------------------------------------------------------------ */
.list-item-card {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--c-border-light);
align-items: flex-start;
transition: background 0.15s, transform 0.1s;
}
.list-item-card--clickable {
cursor: pointer;
}
.list-item-card--clickable:hover {
background: var(--c-surface-2);
}
.list-item-card--clickable:active {
transform: scale(0.98);
}
.list-item-card--milestone {
border-left: 3px solid #f5c518;
}
.list-item-card--inactive {
opacity: 0.55;
filter: grayscale(0.8);
}
/* ------------------------------------------------------------
Linke Spalte: Date-Col oder Meta-Badge
------------------------------------------------------------ */
/* Date-Column (Diary-Style: Wochentag + Tag) */
.list-item-date-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 44px;
text-align: center;
}
.list-item-date-col-weekday {
font-size: var(--text-xs);
font-weight: 600;
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.list-item-date-col-day {
font-size: 1.5rem;
font-weight: 700;
color: var(--c-text);
line-height: 1.1;
}
/* Meta-Badge (Expenses/Health-Style: farbiges Icon im Kreis) */
.list-item-meta-badge {
width: 44px;
height: 44px;
border-radius: 50%;
background: color-mix(in srgb, var(--meta-color, var(--c-primary)) 15%, transparent);
color: var(--meta-color, var(--c-primary));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
/* ------------------------------------------------------------
Body (Hauptinhalt mittig)
------------------------------------------------------------ */
.list-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.list-item-title {
font-weight: 600;
font-size: var(--text-base);
color: var(--c-text);
line-height: 1.3;
}
.list-item-text {
font-size: var(--text-sm);
color: var(--c-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.list-item-meta-row {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--c-text-muted);
flex-wrap: wrap;
}
/* ------------------------------------------------------------
Chips + Micro-Badges (in Item-Body)
------------------------------------------------------------ */
.list-item-chips {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.list-item-chip {
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--chip-color, var(--c-primary)) 15%, transparent);
color: var(--chip-color, var(--c-primary));
}
.list-item-micro-badges {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
margin-top: 2px;
}
.list-item-micro-badge {
padding: 1px 6px;
background: var(--c-surface-2);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--c-text-secondary);
}
/* ------------------------------------------------------------
Rechte Spalte: Thumbnail, Amount, Actions
------------------------------------------------------------ */
.list-item-thumb {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
overflow: hidden;
object-fit: cover;
flex-shrink: 0;
background: var(--c-surface-2);
position: relative;
}
.list-item-thumb-count {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0,0,0,0.65);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: var(--radius-sm);
}
.list-item-amount {
font-weight: 700;
font-size: var(--text-base);
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.list-item-amount--positive { color: var(--c-success); }
.list-item-amount--negative { color: var(--c-danger); }
.list-item-amount--neutral { color: var(--c-text); }
.list-item-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-self: center;
}
.list-item-action-btn {
padding: 6px 8px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
font-size: var(--text-sm);
transition: all 0.15s;
}
.list-item-action-btn:hover {
color: var(--c-text);
background: var(--c-surface-2);
}
.list-item-action-btn--danger:hover {
color: var(--c-danger);
background: color-mix(in srgb, var(--c-danger) 10%, transparent);
}
/* ------------------------------------------------------------
Reminder/Hinweis-Banner (Health-Style)
------------------------------------------------------------ */
.list-reminders-banner {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
}
.list-reminder-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--c-surface);
border-radius: var(--radius-md);
border-left: 3px solid var(--c-text-muted);
font-size: var(--text-sm);
}
.list-reminder-item--urgent { border-left-color: var(--c-danger); }
.list-reminder-item--warning { border-left-color: var(--c-warning, #f59e0b); }
.list-reminder-item--success { border-left-color: var(--c-success); }
/* ------------------------------------------------------------
FAB (Floating Action Button)
------------------------------------------------------------ */
.list-fab {
position: fixed;
bottom: calc(env(safe-area-inset-bottom, 16px) + 16px);
right: 20px;
width: 54px;
height: 54px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 18px rgba(196,132,58,0.4);
font-size: 26px;
z-index: 80;
transition: transform 0.12s, box-shadow 0.12s;
}
.list-fab:active {
transform: scale(0.92);
box-shadow: 0 2px 10px rgba(196,132,58,0.3);
}
/* ------------------------------------------------------------
Load-More + Empty-List in Listen-Context
------------------------------------------------------------ */
.list-load-more {
text-align: center;
padding: var(--space-4);
}

View file

@ -1,65 +0,0 @@
/* ============================================================
BAN YARO Utility-Klassen für häufige Inline-Patterns
Ergänzt design-system.css (Single-Property-Utilities sind dort)
============================================================ */
/* ------------------------------------------------------------
Text + Farb-Kombinationen (häufigste Inline-Patterns)
------------------------------------------------------------ */
.text-xs-muted { font-size: var(--text-xs); color: var(--c-text-muted); }
.text-xs-secondary { font-size: var(--text-xs); color: var(--c-text-secondary); }
.text-sm-muted { font-size: var(--text-sm); color: var(--c-text-muted); }
.text-sm-secondary { font-size: var(--text-sm); color: var(--c-text-secondary); }
/* Caption = Mini-Label/Hinweis unter einem Wert */
.caption {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: 2px;
}
/* ------------------------------------------------------------
Flex-Layouts (kombiniert)
------------------------------------------------------------ */
.flex-gap-2 { display: flex; gap: var(--space-2); }
.flex-gap-3 { display: flex; gap: var(--space-3); }
.flex-col-gap-2 { display: flex; flex-direction: column; gap: var(--space-2); }
.flex-col-gap-3 { display: flex; flex-direction: column; gap: var(--space-3); }
.flex-col-gap-4 { display: flex; flex-direction: column; gap: var(--space-4); }
.flex-center { display: flex; align-items: center; }
.flex-center-gap-1 { display: flex; align-items: center; gap: var(--space-1); }
.flex-center-gap-2 { display: flex; align-items: center; gap: var(--space-2); }
.flex-center-gap-3 { display: flex; align-items: center; gap: var(--space-3); }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-between-gap-2 { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
/* min-width:0 + flex:1 — verhindert Overflow in Flex-Children */
.flex-1-min { flex: 1; min-width: 0; }
/* ------------------------------------------------------------
Spacing-Lücken in design-system.css füllen
------------------------------------------------------------ */
.mb-1 { margin-bottom: var(--space-1); }
.mb-3 { margin-bottom: var(--space-3); }
.mt-1 { margin-top: var(--space-1); }
.mt-3 { margin-top: var(--space-3); }
/* ------------------------------------------------------------
Icon-Größen (statt width:NNpx;height:NNpx inline)
------------------------------------------------------------ */
.icon-xs { width: 12px; height: 12px; }
.icon-sm { width: 14px; height: 14px; }
.icon-md { width: 18px; height: 18px; }
.icon-lg { width: 22px; height: 22px; }
/* ------------------------------------------------------------
Form-Helper
------------------------------------------------------------ */
.label-block {
display: block;
font-size: var(--text-sm);
font-weight: 600;
margin-bottom: var(--space-1);
}

View file

@ -86,14 +86,24 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1120"></script> <script>
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht möglich transparent zu machen)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();
</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=1120"> <link rel="stylesheet" href="/css/design-system.css?v=1099">
<link rel="stylesheet" href="/css/layout.css?v=1120"> <link rel="stylesheet" href="/css/layout.css?v=1099">
<link rel="stylesheet" href="/css/components.css?v=1120"> <link rel="stylesheet" href="/css/components.css?v=1099">
<link rel="stylesheet" href="/css/utilities.css?v=1120">
<link rel="stylesheet" href="/css/lists.css?v=1120">
</head> </head>
<body> <body>
@ -101,8 +111,7 @@
<div id="offline-banner" aria-live="polite" <div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999; style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
background:#1f2937;color:#f3f4f6;font-size:0.78rem;font-weight:500; background:#1f2937;color:#f3f4f6;font-size:0.78rem;font-weight:500;
padding:calc(env(safe-area-inset-top, 0px) + 7px) 16px 7px; padding:7px 16px;align-items:center;justify-content:center;gap:8px;
align-items:center;justify-content:center;gap:8px;
box-shadow:0 2px 8px rgba(0,0,0,.3)"> box-shadow:0 2px 8px rgba(0,0,0,.3)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M213.92,210.62l-160-176A8,8,0,1,0,42.08,45.38L81.06,88.86A152.34,152.34,0,0,0,26.49,130a8,8,0,0,0,11,11.61,136.36,136.36,0,0,1,52-37.29l19.2,21.12A96.09,96.09,0,0,0,67.6,160.59,8,8,0,1,0,79,172.2a80.12,80.12,0,0,1,33.5-23.89L128,165.37V224a8,8,0,0,0,16,0V183.94l69.92,76.92a8,8,0,1,0,11.84-10.76ZM128,141.46,108.42,120A80.38,80.38,0,0,1,128,116a79.91,79.91,0,0,1,19.59,2.43l-19.59,23Zm0-85.46a167.9,167.9,0,0,1,101.51,34.17,8,8,0,1,0,9.72-12.72A183.82,183.82,0,0,0,128,40a183.5,183.5,0,0,0-48.55,6.55L95,64.18A168.23,168.23,0,0,1,128,56Zm57.09,72.41a8,8,0,0,0,11.22-1.36,8,8,0,0,0-1.36-11.22,136.72,136.72,0,0,0-31.62-18.23L178,114.26A120.52,120.52,0,0,1,185.09,128.41Z"/> <path d="M213.92,210.62l-160-176A8,8,0,1,0,42.08,45.38L81.06,88.86A152.34,152.34,0,0,0,26.49,130a8,8,0,0,0,11,11.61,136.36,136.36,0,0,1,52-37.29l19.2,21.12A96.09,96.09,0,0,0,67.6,160.59,8,8,0,1,0,79,172.2a80.12,80.12,0,0,1,33.5-23.89L128,165.37V224a8,8,0,0,0,16,0V183.94l69.92,76.92a8,8,0,1,0,11.84-10.76ZM128,141.46,108.42,120A80.38,80.38,0,0,1,128,116a79.91,79.91,0,0,1,19.59,2.43l-19.59,23Zm0-85.46a167.9,167.9,0,0,1,101.51,34.17,8,8,0,1,0,9.72-12.72A183.82,183.82,0,0,0,128,40a183.5,183.5,0,0,0-48.55,6.55L95,64.18A168.23,168.23,0,0,1,128,56Zm57.09,72.41a8,8,0,0,0,11.22-1.36,8,8,0,0,0-1.36-11.22,136.72,136.72,0,0,0-31.62-18.23L178,114.26A120.52,120.52,0,0,1,185.09,128.41Z"/>
@ -116,8 +125,7 @@
<div id="verify-banner" aria-live="polite" <div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998; style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500; background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
padding:calc(env(safe-area-inset-top, 0px) + 8px) 16px 8px; padding:8px 16px;align-items:center;justify-content:center;gap:10px;
align-items:center;justify-content:center;gap:10px;
box-shadow:0 2px 8px rgba(0,0,0,.2)"> box-shadow:0 2px 8px rgba(0,0,0,.2)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/> <path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
@ -318,7 +326,7 @@
</div> </div>
<div id="header-actions"></div> <div id="header-actions"></div>
<button id="header-user-btn" aria-label="Profil" <button id="header-user-btn" aria-label="Profil"
style="width:44px;height:44px;border-radius:50%;border:2px solid var(--c-border); style="width:36px;height:36px;border-radius:50%;border:2px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;flex-shrink:0; background:var(--c-surface-2);cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;overflow:hidden; display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative"> padding:0;position:relative">
@ -617,11 +625,11 @@
<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=1120"></script> <script src="/js/api.js?v=1099"></script>
<script src="/js/ui.js?v=1120"></script> <script src="/js/ui.js?v=1099"></script>
<script src="/js/app.js?v=1120"></script> <script src="/js/app.js?v=1099"></script>
<script src="/js/worlds.js?v=1120"></script> <script src="/js/worlds.js?v=1099"></script>
<script src="/js/offline-indicator.js?v=1120"></script> <script src="/js/offline-indicator.js?v=1099"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -629,9 +637,130 @@
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script> <script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
<!-- Offline-Banner Logik -->
<script>
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
// Initial prüfen
_updateBanner();
})();
</script>
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Service Worker -->
<script src="/js/boot.js?v=1120"></script> <script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(reg => {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
// Flag nur prüfen, nicht konsumieren — controllerchange konsumiert ihn
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
// Listener VOR update() registrieren — verhindert Race Condition
reg.addEventListener('updatefound', () => _watchSW(reg.installing));
// Falls SW bereits installiert (Seite wurde nach SW-Install neu geladen)
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(err => console.warn('SW Registration failed:', err));
});
// Backup: erneut prüfen wenn App aus dem Hintergrund kommt
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
}
});
// Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'QUEUE_PROCESSED') {
const { synced, failed, total } = e.data;
if (total === 0) return;
if (synced > 0 && window.UI?.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: `${synced} offline gespeicherte Einträge synchronisiert`
);
// Aktuelle Seite neu laden
window.App?.state && window.pages?.[window.App.state.page]?.module?.refresh?.();
}
if (failed > 0 && window.UI?.toast) {
window.UI.toast.warning(`${failed} Eintrag${failed > 1 ? 'e' : ''} noch nicht synchronisiert — kein Netz`);
}
return;
}
if (e.data?.type === 'QUEUE_COUNT') {
const badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data?.type === 'CHECK_NEARBY_ALERTS') {
window.App?._checkNearbyAlerts?.();
}
});
}
</script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1120'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '1099'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;
@ -129,23 +129,16 @@ const App = (() => {
function navigate(pageId, pushHistory = true, params = {}) { function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return; if (!pages[pageId]) return;
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist // Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist
// UND wenn nicht erst kürzlich force-update lief (Cooldown 10 Min) — verhindert Loop
// bei mehreren schnellen Deploys oder iOS-PWA-Cache-Quirks. localStorage überlebt
// App-Restarts (sessionStorage wäre bei PWA-Standalone-close weg).
if (window._byUpdatePending) { if (window._byUpdatePending) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null; const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
let lastForce = 0; if (!modalOpen) {
try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {}
const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000;
if (!modalOpen && !cooldownActive) {
window._byUpdatePending = false; window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || ''); sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId); sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {}
location.href = '/force-update'; location.href = '/force-update';
return; return;
} }
// Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen // Modal offen → beim nächsten Seitenwechsel versuchen
} }
if (window.Worlds?._visible) window.Worlds.hide(); if (window.Worlds?._visible) window.Worlds.hide();

View file

@ -1,13 +0,0 @@
/* Theme-Setup und theme-color für Status-Leiste.
MUSS synchron im <head> VOR den CSS-Links laufen, sonst FOUC. */
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht transparent möglich)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();

View file

@ -1,127 +0,0 @@
/* ============================================================
BAN YARO Boot-Phase
Offline-Banner + Service Worker Registration + Update-Flow
Extrahiert aus index.html für CSP-Härtung (kein unsafe-inline)
============================================================ */
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
_updateBanner();
})();
// ----------------------------------------------------------
// Service Worker Registration + Update-Flow
// ----------------------------------------------------------
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(function(reg) {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') {
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(function(err) { console.warn('SW Registration failed:', err); });
});
// App aus dem Hintergrund: erneut prüfen
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(function(reg) { if (reg) reg.update(); });
}
});
// Backup: controllerchange falls updatefound nicht feuert
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', function(e) {
if (e.data && e.data.type === 'QUEUE_PROCESSED') {
var synced = e.data.synced, failed = e.data.failed, total = e.data.total;
if (total === 0) return;
if (synced > 0 && window.UI && window.UI.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: synced + ' offline gespeicherte Einträge synchronisiert'
);
if (window.App && window.App.state && window.pages) {
var p = window.pages[window.App.state.page];
if (p && p.module && p.module.refresh) p.module.refresh();
}
}
if (failed > 0 && window.UI && window.UI.toast) {
window.UI.toast.warning(failed + ' Eintrag' + (failed > 1 ? 'e' : '') + ' noch nicht synchronisiert — kein Netz');
}
return;
}
if (e.data && e.data.type === 'QUEUE_COUNT') {
var badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data && e.data.type === 'CHECK_NEARBY_ALERTS') {
if (window.App && window.App._checkNearbyAlerts) window.App._checkNearbyAlerts();
}
});
}

View file

@ -1,99 +0,0 @@
/* ============================================================
BAN YARO Landing Page Init
Dark-Mode-Check, Scroll-Animationen, Live-Stats, Stay-In-App
Extrahiert aus landing.html für CSP-Härtung
============================================================ */
// Dark Mode (CSS-Klasse)
(function() {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq.matches) document.documentElement.classList.add('dark');
mq.addEventListener('change', function(e) {
document.documentElement.classList.toggle('dark', e.matches);
});
})();
document.addEventListener('DOMContentLoaded', function() {
// App-Links: kein Redirect-Loop (ersetzt onclick="sessionStorage.setItem(...)")
document.querySelectorAll('[data-stay-in-app]').forEach(function(el) {
el.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Hundebesitzer-Details-Toggle (ersetzt inline onclick)
document.querySelectorAll('[data-toggle-target]').forEach(function(el) {
el.addEventListener('click', function() {
var c = document.getElementById(el.dataset.toggleTarget);
if (!c) return;
c.classList.toggle('open');
var open = c.classList.contains('open');
var openTxt = el.dataset.toggleTextOpen || '▴ Weniger anzeigen';
var closeTxt = el.dataset.toggleTextClose || el.textContent;
if (!el.dataset.toggleTextClose) el.dataset.toggleTextClose = closeTxt;
el.textContent = open ? openTxt : el.dataset.toggleTextClose;
});
});
// Auch ältere App-Links erfassen (Fallback ohne data-stay-in-app)
document.querySelectorAll('a[href="/"], a[href^="/#"]').forEach(function(a) {
a.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Scroll-Animationen
var _observer = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) {
e.target.classList.add('visible');
_observer.unobserve(e.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.outcome-card, .feature-card, .usp-item, .pricing-card').forEach(function(el) {
el.classList.add('fade-up');
_observer.observe(el);
});
document.querySelectorAll('.fade-up').forEach(function(el) {
_observer.observe(el);
});
// Live-Zahlen
var fmt = new Intl.NumberFormat('de-DE');
fetch('/api/stats/public')
.then(function(r) { return r.json(); })
.then(function(d) {
function set(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val);
}
set('big-users', d.users);
set('big-dogs', d.dogs);
set('big-km', d.km);
set('big-posts', d.forum_posts);
set('big-diary', d.diary_entries);
set('big-kotbeutel', d.kotbeutel);
var heroStats = document.getElementById('hero-stats');
if (!heroStats || !d.users) return;
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
})
.catch(function() {});
});

View file

@ -234,42 +234,6 @@ window.OfflineIndicator = (() => {
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
} }
// ----------------------------------------------------------
// Storage-Quota überwachen — iOS-PWA hat ~50MB Limit
// ----------------------------------------------------------
let _storageWarned = false;
async function _checkStorageQuota() {
if (!navigator.storage?.estimate) return;
try {
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
if (!quota) return;
const ratio = usage / quota;
// Ab 80% Auslastung: Tile-Cache aggressiv trimmen (per SW-Message)
if (ratio >= 0.8) {
const cache = await caches.open(CACHE_TILES).catch(() => null);
if (cache) {
const keys = await cache.keys();
// Auf 100 Tiles trimmen (statt 500) bei knappem Speicher
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map(k => cache.delete(k).catch(() => {})));
}
}
// Einmaliger User-Hinweis pro Session bei kritischer Auslastung (>90%)
if (ratio >= 0.9 && !_storageWarned && window.UI?.toast) {
_storageWarned = true;
const mb = Math.round(usage / 1024 / 1024);
const max = Math.round(quota / 1024 / 1024);
window.UI.toast.warning(
`Speicher fast voll (${mb}/${max} MB) — älteste Karten-Tiles werden gelöscht.`,
6000
);
}
}
} catch {}
}
// Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte // Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte
function _prefetchPages() { function _prefetchPages() {
['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost'] ['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost']
@ -341,8 +305,7 @@ window.OfflineIndicator = (() => {
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh(); if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
}); });
} }
_checkStorageQuota(); // beim Init prüfen setInterval(() => { _prefetchData(); refresh(); }, 60_000);
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
} }
return { init, refresh, openStatus }; return { init, refresh, openStatus };

File diff suppressed because it is too large Load diff

View file

@ -56,7 +56,7 @@ window.Page_adoption = (() => {
<input id="adp-rasse" class="form-control" type="text" <input id="adp-rasse" class="form-control" type="text"
placeholder="Rasse filtern…" placeholder="Rasse filtern…"
style="flex:1;min-width:120px;max-width:220px" style="flex:1;min-width:120px;max-width:220px"
value="${UI.escape(_rasseFilter)}"> value="${_esc(_rasseFilter)}">
<button class="btn btn-secondary" id="adp-btn-locate" <button class="btn btn-secondary" id="adp-btn-locate"
style="white-space:nowrap"> style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort ${UI.icon('map-pin')} Mein Standort
@ -270,7 +270,7 @@ window.Page_adoption = (() => {
content.innerHTML = ` content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)"> <div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div> <div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 class="mb-2">Finde Hunde in deiner Nähe</h3> <h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto"> <p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
in deiner Umgebung zu finden. in deiner Umgebung zu finden.
@ -306,7 +306,7 @@ window.Page_adoption = (() => {
if (!animals.length) { if (!animals.length) {
content.innerHTML = ` content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${UI.escape(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`} ${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
</p> </p>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px"> <div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste" <a href="https://www.tierheimhelden.de/hunde/liste"
@ -339,7 +339,7 @@ window.Page_adoption = (() => {
</p> </p>
<a href="https://www.tierheimhelden.de/hunde/liste" <a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary text-sm"> class="btn btn-secondary" style="font-size:var(--text-sm)">
${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde ${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde
</a> </a>
</div> </div>
@ -355,7 +355,7 @@ window.Page_adoption = (() => {
function _animalCard(a) { function _animalCard(a) {
const foto = a.foto_url const foto = a.foto_url
? `<img src="${UI.escape(a.foto_url)}" alt="${UI.escape(a.name)}" ? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
style="width:100%;height:100%;object-fit:cover" style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">` onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>'; : '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
@ -366,7 +366,7 @@ window.Page_adoption = (() => {
const tierheim = a.tierheim || ''; const tierheim = a.tierheim || '';
return ` return `
<div data-adp-url="${UI.escape(a.adoptions_url)}" <div data-adp-url="${_esc(a.adoptions_url)}"
style="border-radius:var(--radius-md);overflow:hidden; style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer; background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08); box-shadow:0 1px 4px rgba(0,0,0,0.08);
@ -379,16 +379,16 @@ window.Page_adoption = (() => {
<div style="padding:var(--space-2) var(--space-2) var(--space-3)"> <div style="padding:var(--space-2) var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(a.name)} ${_esc(a.name)}
</div> </div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(rasseTxt)} ${_esc(rasseTxt)}
</div>` : ''} </div>` : ''}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)"> <div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3); ${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${UI.escape(alterTxt)} ${_esc(alterTxt)}
</span>` : ''} </span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3); ${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -396,12 +396,12 @@ window.Page_adoption = (() => {
</span>` : ''} </span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe); ${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)"> border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${UI.escape(distTxt)} ${_esc(distTxt)}
</span>` : ''} </span>` : ''}
</div> </div>
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1); ${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${UI.escape(tierheim)}"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
${UI.icon('house-line')} ${UI.escape(tierheim)} ${UI.icon('house-line')} ${_esc(tierheim)}
</div>` : ''} </div>` : ''}
</div> </div>
</div> </div>
@ -434,7 +434,7 @@ window.Page_adoption = (() => {
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km ${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
</p> </p>
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${shelters.map(s => _shelterRow(s)).join('')} ${shelters.map(s => _shelterRow(s)).join('')}
</div> </div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)"> <div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
@ -444,12 +444,12 @@ window.Page_adoption = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<a href="https://www.tierheimhelden.de" <a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm text-sm"> class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
${UI.icon('arrow-square-out')} Tierheimhelden.de ${UI.icon('arrow-square-out')} Tierheimhelden.de
</a> </a>
<a href="https://www.tierschutz.com/tierheimsuche/" <a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm text-sm"> class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
${UI.icon('magnifying-glass')} tierschutz.com ${UI.icon('magnifying-glass')} tierschutz.com
</a> </a>
</div> </div>
@ -459,7 +459,7 @@ window.Page_adoption = (() => {
function _shelterRow(s) { function _shelterRow(s) {
return ` return `
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer" <a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
style="display:flex;align-items:center;gap:var(--space-3); style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md); padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit; background:var(--c-surface-2);text-decoration:none;color:inherit;
@ -473,13 +473,13 @@ window.Page_adoption = (() => {
font-size:1.2rem"> font-size:1.2rem">
🏠 🏠
</div> </div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(s.name)} ${_esc(s.name)}
</div> </div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.escape(s.plz)} ${UI.escape(s.stadt)} ${_esc(s.plz)} ${_esc(s.stadt)}
</div> </div>
</div> </div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0"> <div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
@ -520,7 +520,7 @@ window.Page_adoption = (() => {
content.innerHTML = ` content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)"> <div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div> <div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 class="mb-2">Noch keine Hunde zur Weitervermittlung</h3> <h3 style="margin-bottom:var(--space-2)">Noch keine Hunde zur Weitervermittlung</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto"> <p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Hier können Halter Hunde privat zur Weitervermittlung anbieten Hier können Halter Hunde privat zur Weitervermittlung anbieten
zum Beispiel bei Umzug, Krankheit oder Allergie. zum Beispiel bei Umzug, Krankheit oder Allergie.
@ -530,7 +530,7 @@ window.Page_adoption = (() => {
${UI.icon('plus')} Hund zur Vermittlung anbieten ${UI.icon('plus')} Hund zur Vermittlung anbieten
</button> </button>
` : ` ` : `
<p class="text-sm-secondary"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Bitte anmelden, um ein Inserat zu erstellen. Bitte anmelden, um ein Inserat zu erstellen.
</p> </p>
`} `}
@ -556,8 +556,8 @@ window.Page_adoption = (() => {
${isLoggedIn && _myListings && _myListings.length ? ` ${isLoggedIn && _myListings && _myListings.length ? `
<div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)"> <div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<h4 class="mb-3">Meine Inserate</h4> <h4 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${_myListings.map(l => _myListingRow(l)).join('')} ${_myListings.map(l => _myListingRow(l)).join('')}
</div> </div>
</div> </div>
@ -610,7 +610,7 @@ window.Page_adoption = (() => {
function _communityCard(l) { function _communityCard(l) {
const foto = l.foto_url const foto = l.foto_url
? `<img src="${UI.escape(l.foto_url)}" alt="${UI.escape(l.name)}" ? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
style="width:100%;height:100%;object-fit:cover" style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">` onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>'; : '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
@ -635,11 +635,11 @@ window.Page_adoption = (() => {
const interestBtn = l.user_interested const interestBtn = l.user_interested
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)" ? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${UI.escape(l.id)}" data-adp-interested="true"> data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
Bereits gemeldet Bereits gemeldet
</button>` </button>`
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)" : `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${UI.escape(l.id)}" data-adp-interested="false" data-adp-interest="${_esc(l.id)}" data-adp-interested="false"
${!isActive ? 'disabled' : ''}> ${!isActive ? 'disabled' : ''}>
Interesse bekunden Interesse bekunden
</button>`; </button>`;
@ -657,7 +657,7 @@ window.Page_adoption = (() => {
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<span style="color:#fff;font-weight:700;font-size:var(--text-sm); <span style="color:#fff;font-weight:700;font-size:var(--text-sm);
background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px"> background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
${UI.escape(statusLabel)} ${_esc(statusLabel)}
</span> </span>
</div> </div>
` : ''} ` : ''}
@ -666,17 +666,17 @@ window.Page_adoption = (() => {
<div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)"> <div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.name)} ${_esc(l.name)}
</div> </div>
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.rasse)} ${_esc(l.rasse)}
</div>` : ''} </div>` : ''}
<!-- Badges --> <!-- Badges -->
<div style="display:flex;gap:4px;flex-wrap:wrap"> <div style="display:flex;gap:4px;flex-wrap:wrap">
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3); ${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${UI.escape(alterLabel)} ${_esc(alterLabel)}
</span>` : ''} </span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3); ${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -684,14 +684,14 @@ window.Page_adoption = (() => {
</span>` : ''} </span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe); ${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)"> border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${UI.escape(distTxt)} ${_esc(distTxt)}
</span>` : ''} </span>` : ''}
</div> </div>
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${UI.escape(ort)}</div>` : ''} ${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box; overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical"> -webkit-line-clamp:2;-webkit-box-orient:vertical">
${UI.escape(l.beschreibung)} ${_esc(l.beschreibung)}
</div>` : ''} </div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)"> ${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
@ -714,23 +714,23 @@ window.Page_adoption = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2); <div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);border:1px solid var(--c-border)"> background:var(--c-surface-2);border:1px solid var(--c-border)">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.name)} ${_esc(l.name)}
</div> </div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''} ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
</div> </div>
</div> </div>
<select class="form-control" style="width:auto;font-size:var(--text-xs)" <select class="form-control" style="width:auto;font-size:var(--text-xs)"
data-adp-status-change="${UI.escape(l.id)}"> data-adp-status-change="${_esc(l.id)}">
${statusOptions.map(o => ` ${statusOptions.map(o => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option> <option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')} `).join('')}
</select> </select>
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap" <button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
data-adp-delete="${UI.escape(l.id)}"> data-adp-delete="${_esc(l.id)}">
${UI.icon('trash')} Löschen ${UI.icon('trash')} Löschen
</button> </button>
</div> </div>
@ -764,7 +764,7 @@ window.Page_adoption = (() => {
// Interesse bekunden — Modal mit optionaler Nachricht // Interesse bekunden — Modal mit optionaler Nachricht
const body = ` const body = `
<form id="adp-interest-form" class="flex-col-gap-3"> <form id="adp-interest-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)"> <p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Du kannst optional eine Nachricht an den Anbieter schicken. Du kannst optional eine Nachricht an den Anbieter schicken.
</p> </p>
@ -816,9 +816,9 @@ window.Page_adoption = (() => {
} }
const body = ` const body = `
<form id="adp-create-form" class="flex-col-gap-3"> <form id="adp-create-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Name <span class="text-danger">*</span></label> <label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
<input class="form-control" name="name" required placeholder="z.B. Bello"> <input class="form-control" name="name" required placeholder="z.B. Bello">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -849,7 +849,7 @@ window.Page_adoption = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">PLZ</label> <label class="form-label">PLZ</label>
<input class="form-control" name="plz" inputmode="numeric" maxlength="5" <input class="form-control" name="plz" inputmode="numeric" maxlength="5"
placeholder="z.B. 80331" value="${UI.escape(_lat ? '' : '')}"> placeholder="z.B. 80331" value="${_esc(_lat ? '' : '')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Ort</label> <label class="form-label">Ort</label>
@ -857,7 +857,7 @@ window.Page_adoption = (() => {
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Beschreibung <span class="text-danger">*</span></label> <label class="form-label">Beschreibung <span style="color:var(--c-danger)">*</span></label>
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80" <textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea> placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div> <div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
@ -876,7 +876,7 @@ window.Page_adoption = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="adp-create-form" class="btn btn-primary w-full" id="adp-create-submit"> <button type="submit" form="adp-create-form" class="btn btn-primary" style="width:100%" id="adp-create-submit">
${UI.icon('plus')} Inserat erstellen ${UI.icon('plus')} Inserat erstellen
</button> </button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
@ -941,6 +941,15 @@ window.Page_adoption = (() => {
return 'Senior'; return 'Senior';
} }
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC API // PUBLIC API
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -1,318 +0,0 @@
/* ============================================================
BAN YARO Züchter-Profil-Editor
Selbstverwaltung des öffentlichen Züchter-Profils.
============================================================ */
window.Page_breeder_editor = (() => {
let _container = null;
let _data = null; // { profile, litters, storage_mb, storage_limit_mb }
async function init(container) {
_container = container;
_container.innerHTML = `<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">${UI.skeleton(5)}</div>`;
await _load();
}
function refresh() { _load(); }
function onDogChange() {}
async function _load() {
try {
_data = await API.get('/breeder/my-editor');
_render();
} catch (e) {
_container.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">${e.message}</div>`;
}
}
function _render() {
const { profile: p, litters, storage_mb, storage_limit_mb } = _data;
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="margin-bottom:var(--space-5)">
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">Mein Züchter-Profil</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Gestalte deine öffentliche Profilseite Fotos, Videos und Infos zu deinen Würfen.
</p>
</div>
<!-- Logo & Grundinfos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Logo / Titelbild</div>
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="be-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
background:var(--c-surface-2);overflow:hidden;flex-shrink:0;
display:flex;align-items:center;justify-content:center">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:100%;height:100%;object-fit:cover">`
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
</div>
<div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
Logo hochladen
<input type="file" id="be-logo-input" accept="image/*" class="hidden">
</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Quadratisch · max. 5 MB · HEIC wird unterstützt
</div>
</div>
</div>
</div>
<!-- Profil-Texte -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Profil-Texte</div>
<form id="be-text-form" class="flex-col-gap-3">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Zwingername *</label>
<input class="form-control" name="zwingername" type="text" required
value="${UI.escape(p.zwingername || '')}">
</div>
<div class="form-group">
<label class="form-label">Rasse(n)</label>
<input class="form-control" name="rasse_text" type="text"
value="${UI.escape(p.rasse_text || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Slogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
<input class="form-control" name="tagline" type="text" maxlength="80"
placeholder="z. B. Liebevolle Aufzucht seit 2010 · VDH-anerkannt"
value="${UI.escape(p.tagline || '')}">
</div>
<div class="form-group">
<label class="form-label">Über uns / Zwingerbeschreibung</label>
<textarea class="form-control" name="beschreibung" rows="4" maxlength="800"
placeholder="Wer seid ihr, was ist euch bei der Zucht wichtig?">${UI.escape(p.beschreibung || '')}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Stadt</label>
<input class="form-control" name="stadt" type="text" value="${UI.escape(p.stadt || '')}">
</div>
<div class="form-group">
<label class="form-label">Verein</label>
<input class="form-control" name="verein" type="text" value="${UI.escape(p.verein || '')}">
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url"
placeholder="https://" value="${UI.escape(p.website || '')}">
</div>
<div class="form-group">
<label class="form-label">Instagram</label>
<input class="form-control" name="instagram" type="text"
placeholder="@zwingername" value="${UI.escape(p.instagram || '')}">
</div>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
Profil speichern
</button>
</form>
</div>
<!-- Profil-Fotos & Videos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
Profil-Fotos & Videos
</div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
</div>
${_storageBar(storage_mb, storage_limit_mb)}
<div id="be-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
${_renderPhotoGrid(p.photos || [])}
</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
Foto / Video hinzufügen
<input type="file" id="be-profile-photo-input" accept="image/*,video/*" class="hidden">
</label>
</div>
<!-- Würfe Schnellupload -->
${litters.length ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">
Aktuelle Würfe Fotos & Videos
</div>
<div class="flex-col-gap-3">
${litters.map(l => _renderLitterCard(l)).join('')}
</div>
</div>` : ''}
</div>
`;
_bindEvents();
}
function _renderPhotoGrid(photos) {
return photos.map((ph, i) => {
const isVid = ph.media_type === 'video' || (ph.url || '').endsWith('.mp4');
return `
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(ph.url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(ph.thumbnail_url || ph.url)}" style="width:100%;height:100%;object-fit:cover">`}
${ph.is_primary ? `<div style="position:absolute;top:4px;left:4px;background:rgba(196,132,58,.9);border-radius:3px;padding:1px 5px;font-size:9px;color:#fff;font-weight:700">LOGO</div>` : ''}
<button class="be-photo-del" data-id="${ph.id}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
${!ph.is_primary ? `<button class="be-photo-primary" data-id="${ph.id}"
title="Als Logo setzen"
style="position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,.55);
border:none;border-radius:3px;padding:1px 5px;font-size:9px;cursor:pointer;color:#fff">Logo</button>` : ''}
</div>`;
}).join('');
}
function _renderLitterCard(l) {
const label = l.geburtsdatum
? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}`
: `Wurf #${l.id}`;
const info = [
l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null,
`${l.foto_count} Medien`,
].filter(Boolean).join(' · ');
return `
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(label)}</div>
<div class="text-xs-muted">${info}</div>
</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#upload-simple"></use></svg>
Upload
<input type="file" class="be-litter-input" data-litter-id="${l.id}"
data-label="${UI.escape(label)}" accept="image/*,video/*" class="hidden">
</label>
</div>
</div>`;
}
function _storageBar(usedMb, limitMb) {
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px"></div>
</div>
<span style="white-space:nowrap">${usedMb.toFixed(1)} / ${limitMb} MB</span>
</div>`;
}
function _bindEvents() {
const el = _container;
// Logo hochladen
el.querySelector('#be-logo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(_data.profile.id));
fd.append('is_primary', '1');
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success('Logo gespeichert.');
await _load();
} catch (err) { UI.toast.error(err.message); }
});
// Profil-Texte speichern
el.querySelector('#be-text-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.put('/breeder/profile', fd);
_data.profile = { ..._data.profile, ...fd };
UI.toast.success('Profil gespeichert.');
});
});
// Profil-Foto/-Video hochladen
el.querySelector('#be-profile-photo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
if (isVideo) UI.toast.info('Video wird komprimiert das kann 12 Minuten dauern …', 120_000);
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(_data.profile.id));
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
await _load();
} catch (err) { UI.toast.error(err.message); }
});
// Foto löschen
el.querySelectorAll('.be-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Löschen?')) return;
try {
await API.breederPhotos.remove(parseInt(btn.dataset.id));
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Als Logo setzen
el.querySelectorAll('.be-photo-primary').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.patch(`/breeder/photos/${btn.dataset.id}/primary`, {});
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Wurf-Upload
el.querySelectorAll('.be-litter-input').forEach(input => {
input.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const litterId = input.dataset.litterId;
const label = input.dataset.label;
if (isVideo) UI.toast.info('Video wird komprimiert das kann 12 Minuten dauern …', 120_000);
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'litter');
fd.append('entity_id', litterId);
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success(`${isVideo ? 'Video' : 'Foto'} zu „${label}" hinzugefügt.`);
// Foto-Count aktualisieren
const litter = _data.litters.find(l => String(l.id) === String(litterId));
if (litter) litter.foto_count++;
_render();
} catch (err) { UI.toast.error(err.message); }
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -7,6 +7,8 @@ window.Page_breeder = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -49,7 +51,7 @@ window.Page_breeder = (() => {
} catch (e) { } catch (e) {
document.getElementById('breeder-profile-body').innerHTML = document.getElementById('breeder-profile-body').innerHTML =
`<div style="padding:var(--space-8);text-align:center;color:var(--c-text-secondary)"> `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">
${UI.icon('magnifying-glass')} ${UI.escape(e.message || 'Züchter nicht gefunden.')} ${UI.icon('magnifying-glass')} ${_esc(e.message || 'Züchter nicht gefunden.')}
</div>`; </div>`;
} }
} }
@ -73,22 +75,22 @@ window.Page_breeder = (() => {
padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative"> padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<div style="max-width:640px;margin:0 auto"> <div style="max-width:640px;margin:0 auto">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap"> <div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<p style="margin:0 0 var(--space-1);font-size:var(--text-xs);opacity:.7;text-transform:uppercase;letter-spacing:.1em"> <p style="margin:0 0 var(--space-1);font-size:var(--text-xs);opacity:.7;text-transform:uppercase;letter-spacing:.1em">
${UI.icon('seal-check')} Verifizierter Züchter ${UI.icon('seal-check')} Verifizierter Züchter
</p> </p>
<h1 style="margin:0 0 var(--space-2);font-size:clamp(1.3rem,4vw,1.9rem);font-weight:800;line-height:1.2;word-break:break-word"> <h1 style="margin:0 0 var(--space-2);font-size:clamp(1.3rem,4vw,1.9rem);font-weight:800;line-height:1.2;word-break:break-word">
${UI.escape(p.zwingername)} ${_esc(p.zwingername)}
</h1> </h1>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center">
${p.rasse_text ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.escape(p.rasse_text)}</span>` : ''} ${p.rasse_text ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${_esc(p.rasse_text)}</span>` : ''}
${p.vdh_mitglied ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.icon('certificate')} VDH</span>` : ''} ${p.vdh_mitglied ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.icon('certificate')} VDH</span>` : ''}
${p.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${UI.escape(p.stadt)}</span>` : ''} ${p.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${_esc(p.stadt)}</span>` : ''}
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${UI.escape(seit)}</span>` : ''} ${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${_esc(seit)}</span>` : ''}
</div> </div>
</div> </div>
${p.logo_url ${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" alt="Zwinger-Logo" ? `<img src="${_esc(p.logo_url)}" alt="Zwinger-Logo"
style="width:72px;height:72px;border-radius:50%;object-fit:cover; style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:3px solid rgba(255,255,255,.5);flex-shrink:0;box-shadow:0 2px 12px rgba(0,0,0,.25)" border:3px solid rgba(255,255,255,.5);flex-shrink:0;box-shadow:0 2px 12px rgba(0,0,0,.25)"
onerror="this.style.display='none'">` onerror="this.style.display='none'">`
@ -115,7 +117,7 @@ window.Page_breeder = (() => {
Anmelden um zu schreiben Anmelden um zu schreiben
</button>` </button>`
} }
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer" ${p.website ? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="background:rgba(255,255,255,.2);color:white;border:1px solid rgba(255,255,255,.4); style="background:rgba(255,255,255,.2);color:white;border:1px solid rgba(255,255,255,.4);
border-radius:999px;padding:var(--space-2) var(--space-5); border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:600;font-size:var(--text-sm);text-decoration:none; font-weight:600;font-size:var(--text-sm);text-decoration:none;
@ -132,7 +134,7 @@ window.Page_breeder = (() => {
${p.beschreibung ? ` ${p.beschreibung ? `
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg); <div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);margin-bottom:var(--space-4)"> padding:var(--space-4);margin-bottom:var(--space-4)">
<p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${UI.escape(p.beschreibung)}</p> <p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${_esc(p.beschreibung)}</p>
</div>` : ''} </div>` : ''}
<!-- Zuchthunde --> <!-- Zuchthunde -->
@ -155,7 +157,7 @@ window.Page_breeder = (() => {
display:flex;align-items:center;gap:var(--space-2)"> display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('baby')} Aktuelle Würfe ${UI.icon('baby')} Aktuelle Würfe
</h2> </h2>
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${p.wuerfe.map(w => _wurfCard(w)).join('')} ${p.wuerfe.map(w => _wurfCard(w)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -190,8 +192,8 @@ window.Page_breeder = (() => {
${p.website ? ` ${p.website ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">Website</dt> <dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
<dd style="margin:0"><a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer" <dd style="margin:0"><a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${UI.escape(p.website)}</a></dd> style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd>
</div>` : ''} </div>` : ''}
${seit ? _dl('Züchter seit', seit) : ''} ${seit ? _dl('Züchter seit', seit) : ''}
</dl> </dl>
@ -199,7 +201,7 @@ window.Page_breeder = (() => {
<!-- Fotos / Gallery --> <!-- Fotos / Gallery -->
${p.fotos?.length ? ` ${p.fotos?.length ? `
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700; <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;gap:var(--space-2)"> display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('images')} Galerie ${UI.icon('images')} Galerie
@ -207,11 +209,11 @@ window.Page_breeder = (() => {
</h2> </h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)"> <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)">
${p.fotos.map((ph, i) => ` ${p.fotos.map((ph, i) => `
<a href="${UI.escape(ph.url)}" target="_blank" rel="noopener noreferrer" <a href="${_esc(ph.url)}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden; style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'}; border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};
aspect-ratio:1;position:relative"> aspect-ratio:1;position:relative">
<img src="${UI.escape(ph.thumb)}" alt="${UI.escape(ph.caption)}" <img src="${_esc(ph.thumb)}" alt="${_esc(ph.caption)}"
loading="${i < 6 ? 'eager' : 'lazy'}" loading="${i < 6 ? 'eager' : 'lazy'}"
style="width:100%;height:100%;object-fit:cover;display:block" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'"> onerror="this.parentElement.style.display='none'">
@ -219,12 +221,12 @@ window.Page_breeder = (() => {
color:white;font-size:9px;font-weight:700;border-radius:999px;padding:1px 6px">Logo</span>` : ''} color:white;font-size:9px;font-weight:700;border-radius:999px;padding:1px 6px">Logo</span>` : ''}
${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0; ${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.6)); background:linear-gradient(transparent,rgba(0,0,0,.6));
color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${UI.escape(ph.caption)}</div>` : ''} color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${_esc(ph.caption)}</div>` : ''}
</a>`).join('')} </a>`).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
<div id="breeder-photos-section" class="hidden"></div> <div id="breeder-photos-section" style="display:none"></div>
</div>`; </div>`;
@ -249,18 +251,18 @@ window.Page_breeder = (() => {
const augeTest = h.health_tests?.find(t => t.test_typ === 'augen'); const augeTest = h.health_tests?.find(t => t.test_typ === 'augen');
const testPills = [ const testPills = [
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${UI.escape(hdTest.ergebnis)}</span>` : '', hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${_esc(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${UI.escape(edTest.ergebnis)}</span>` : '', edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '',
augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '', augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '',
].filter(Boolean).join(''); ].filter(Boolean).join('');
const titlePills = (h.titel || []).map(t => const titlePills = (h.titel || []).map(t =>
`<span style="background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e); `<span style="background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e);
border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${UI.escape(t)}</span>` border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${_esc(t)}</span>`
).join(''); ).join('');
const genBadge = h.gentests_total > 0 const genBadge = h.gentests_total > 0
? `<span class="text-xs-muted"> ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${h.gentests_clear}/${h.gentests_total} Gentests frei ${h.gentests_clear}/${h.gentests_total} Gentests frei
</span>` </span>`
: ''; : '';
@ -269,12 +271,12 @@ window.Page_breeder = (() => {
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg); <div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)"> padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<span class="text-primary">${gIcon}</span> <span style="color:var(--c-primary)">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(h.name)}</span> <span style="font-weight:700;font-size:var(--text-sm)">${_esc(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${UI.escape(h.rufname)}"</span>` : ''} ${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${_esc(h.rufname)}"</span>` : ''}
${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''} ${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''}
</div> </div>
${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(h.farbe)}</p>` : ''} ${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(h.farbe)}</p>` : ''}
${testPills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${testPills}</div>` : ''} ${testPills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${testPills}</div>` : ''}
${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''} ${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''}
${genBadge} ${genBadge}
@ -316,16 +318,16 @@ window.Page_breeder = (() => {
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg); <div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3) var(--space-4)"> padding:var(--space-3) var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(eltern)}</span> <span style="font-weight:700;font-size:var(--text-sm)">${_esc(eltern)}</span>
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40; <span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span> border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
</div> </div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)"> <div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${datum ? `<span>${UI.icon('calendar-dots')} ${UI.escape(datum)}</span>` : ''} ${datum ? `<span>${UI.icon('calendar-dots')} ${_esc(datum)}</span>` : ''}
${w.welpen_gesamt ? `<span>${UI.icon('dog')} ${w.welpen_verfuegbar ?? '?'}/${w.welpen_gesamt} verfügbar</span>` : ''} ${w.welpen_gesamt ? `<span>${UI.icon('dog')} ${w.welpen_verfuegbar ?? '?'}/${w.welpen_gesamt} verfügbar</span>` : ''}
${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${UI.escape(w.preis_spanne)}</span>` : ''} ${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${_esc(w.preis_spanne)}</span>` : ''}
</div> </div>
${w.beschreibung ? `<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">${UI.escape(w.beschreibung)}</p>` : ''} ${w.beschreibung ? `<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">${_esc(w.beschreibung)}</p>` : ''}
</div>`; </div>`;
} }
@ -338,12 +340,12 @@ window.Page_breeder = (() => {
return ` return `
<div> <div>
<p style="margin:0 0 var(--space-2);font-size:var(--text-xs);font-weight:700; <p style="margin:0 0 var(--space-2);font-size:var(--text-xs);font-weight:700;
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${UI.escape(label)}</p> color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${_esc(label)}</p>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${stats.map(r => ` ${stats.map(r => `
<div style="display:flex;align-items:center;gap:6px;font-size:var(--text-sm)"> <div style="display:flex;align-items:center;gap:6px;font-size:var(--text-sm)">
<span style="font-weight:700">${UI.escape(r.ergebnis || '—')}</span> <span style="font-weight:700">${_esc(r.ergebnis || '—')}</span>
<span class="text-muted">${r.cnt}×</span> <span style="color:var(--c-text-muted)">${r.cnt}×</span>
<span style="background:var(--c-border);border-radius:999px;height:6px; <span style="background:var(--c-border);border-radius:999px;height:6px;
width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span> width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span>
</div>`).join('')} </div>`).join('')}
@ -357,8 +359,8 @@ window.Page_breeder = (() => {
function _dl(label, value) { function _dl(label, value) {
if (!value) return ''; if (!value) return '';
return `<div style="display:flex;gap:var(--space-2);align-items:baseline"> return `<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">${UI.escape(label)}</dt> <dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">${_esc(label)}</dt>
<dd style="margin:0;font-size:var(--text-sm)">${UI.escape(String(value))}</dd> <dd style="margin:0;font-size:var(--text-sm)">${_esc(String(value))}</dd>
</div>`; </div>`;
} }
@ -375,16 +377,16 @@ window.Page_breeder = (() => {
const photos = await API.breederPhotos.list('breeder', breederId); const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos?.length) return; if (!photos?.length) return;
section.innerHTML = ` section.innerHTML = `
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700"> <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('images')} Fotos ${UI.icon('images')} Fotos
</h2> </h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${photos.map(ph => ` ${photos.map(ph => `
<a href="${UI.escape(ph.url||'')}" target="_blank" rel="noopener noreferrer" <a href="${_esc(ph.url||'')}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden; style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1"> border:1px solid var(--c-border);aspect-ratio:1">
<img src="${UI.escape(ph.thumbnail_url||ph.url||'')}" alt="${UI.escape(ph.caption||'')}" <img src="${_esc(ph.thumbnail_url||ph.url||'')}" alt="${_esc(ph.caption||'')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'"> onerror="this.parentElement.style.display='none'">
</a>`).join('')} </a>`).join('')}

View file

@ -122,7 +122,7 @@ window.Page_chat = (() => {
el.innerHTML = convs.map(c => { el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase(); const initials = (c.partner_name || '?')[0].toUpperCase();
const preview = c.last_text const preview = c.last_text
? UI.escape(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '') ? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
: '<em style="opacity:0.6">Noch keine Nachrichten</em>'; : '<em style="opacity:0.6">Noch keine Nachrichten</em>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : ''; const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0 const badge = c.unread_count > 0
@ -138,7 +138,7 @@ window.Page_chat = (() => {
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''} ${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
</div> </div>
<div class="chat-conv-info"> <div class="chat-conv-info">
<div class="chat-conv-name">${UI.escape(c.partner_name)}</div> <div class="chat-conv-name">${_esc(c.partner_name)}</div>
<div class="chat-conv-preview">${preview}</div> <div class="chat-conv-preview">${preview}</div>
</div> </div>
<div class="chat-conv-meta"> <div class="chat-conv-meta">
@ -178,7 +178,7 @@ window.Page_chat = (() => {
</button>`} </button>`}
<div style="position:relative;flex-shrink:0"> <div style="position:relative;flex-shrink:0">
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div> <div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" class="hidden"></span> <span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
</div> </div>
<span class="chat-thread-partner" id="chat-partner-name"></span> <span class="chat-thread-partner" id="chat-partner-name"></span>
</div> </div>
@ -188,7 +188,7 @@ window.Page_chat = (() => {
</div> </div>
</div> </div>
<div class="chat-input-bar"> <div class="chat-input-bar">
<input type="file" id="chat-photo-input" accept="image/*" class="hidden" <input type="file" id="chat-photo-input" accept="image/*" style="display:none"
onchange="Page_chat._onPhotoSelected(this)"> onchange="Page_chat._onPhotoSelected(this)">
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden"> <button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden">
<svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
@ -332,10 +332,10 @@ window.Page_chat = (() => {
} }
if (m.text) { if (m.text) {
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') + bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
UI.escape(m.text) + _esc(m.text) +
(m.media_url ? `</div>` : ''); (m.media_url ? `</div>` : '');
} }
if (!bubbleContent) bubbleContent = UI.escape(m.text); if (!bubbleContent) bubbleContent = _esc(m.text);
html += ` html += `
<div class="chat-bubble-row ${rowClass}"> <div class="chat-bubble-row ${rowClass}">
@ -450,6 +450,13 @@ window.Page_chat = (() => {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
} }
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/\n/g, '<br>');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// Neue Nachricht — Freundesliste als Picker // Neue Nachricht — Freundesliste als Picker
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -212,7 +212,7 @@ window.Page_diary = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button> </button>
</div> </div>
<div id="diary-stats-bar" class="diary-stats-bar hidden"></div> <div id="diary-stats-bar" class="diary-stats-bar" style="display:none"></div>
<div id="diary-view-content"> <div id="diary-view-content">
<div id="diary-list"></div> <div id="diary-list"></div>
</div> </div>
@ -295,7 +295,7 @@ window.Page_diary = (() => {
`; `;
card.innerHTML = ` card.innerHTML = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div> <div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold); <div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase; color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)"> letter-spacing:.06em;margin-bottom:var(--space-1)">
@ -963,7 +963,7 @@ window.Page_diary = (() => {
// Hunde-Chips (bei mehreren Hunden) // Hunde-Chips (bei mehreren Hunden)
const dogsHtml = dogIds.length > 1 const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs mb-3"> ? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
${dogIds.map(did => { ${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did); const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip"> return dog ? `<div class="diary-dog-chip">
@ -1279,7 +1279,7 @@ window.Page_diary = (() => {
value="${entry?.datum || today}" required> value="${entry?.datum || today}" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Titel <span class="text-secondary">(optional)</span></label> <label class="form-label">Titel <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="titel" <input class="form-control" type="text" name="titel"
value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag"> value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div> </div>
@ -1293,10 +1293,10 @@ window.Page_diary = (() => {
<div id="diary-existing-media"></div> <div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid --> <!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid hidden"></div> <div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input multiple für Mehrfachauswahl --> <!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple class="hidden"> <input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) --> <!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center"> <label for="diary-media-input" class="btn btn-secondary" style="cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
@ -1305,7 +1305,7 @@ window.Page_diary = (() => {
</label> </label>
</div> </div>
<div class="form-group" id="diary-location-group"> <div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label> <label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<!-- Karte (Lesemodus, Edit per Button aktivierbar) --> <!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative"> <div style="position:relative">
@ -1318,7 +1318,7 @@ window.Page_diary = (() => {
</div> </div>
<!-- POI-Name + Aktionen --> <!-- POI-Name + Aktionen -->
<div class="mt-2"> <div style="margin-top:var(--space-2)">
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}"> <div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip"> <div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
@ -1341,7 +1341,7 @@ window.Page_diary = (() => {
${dogPickerHtml} ${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)"> <div class="form-group" style="margin-top:var(--space-5)">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb" <input type="checkbox" name="is_milestone" id="diary-milestone-cb"
${entry?.is_milestone ? 'checked' : ''} class="hidden"> ${entry?.is_milestone ? 'checked' : ''} style="display:none">
<button type="button" id="diary-milestone-btn" <button type="button" id="diary-milestone-btn"
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}"> class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
@ -1353,10 +1353,10 @@ window.Page_diary = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="diary-form" class="btn btn-primary w-full"> <button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Erstellen'} ${isEdit ? 'Speichern' : 'Erstellen'}
</button> </button>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''} ${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div> </div>
@ -1843,32 +1843,32 @@ window.Page_diary = (() => {
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>. <strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p> </p>
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
<label class="import-format-card" id="fmt-nsx"> <label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked class="hidden"> <input type="radio" name="import-fmt" value="nsx" checked style="display:none">
<div class="import-format-icon"> <div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div> </div>
<div> <div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div> <div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
<div class="text-xs-muted">.nsx-Datei aus dem NoteStation-Export</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
</div> </div>
</label> </label>
<label class="import-format-card" id="fmt-csv"> <label class="import-format-card" id="fmt-csv">
<input type="radio" name="import-fmt" value="csv" class="hidden"> <input type="radio" name="import-fmt" value="csv" style="display:none">
<div class="import-format-icon"> <div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div> </div>
<div> <div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div> <div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
<div class="text-xs-muted">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div> </div>
</label> </label>
</div> </div>
<div class="mt-4"> <div style="margin-top:var(--space-4)">
<label class="form-label">Datei auswählen</label> <label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input" <input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer"> accept=".nsx,.csv" style="cursor:pointer">
@ -1917,7 +1917,7 @@ window.Page_diary = (() => {
: await API.importData.csv(dogId, file); : await API.importData.csv(dogId, file);
const errHtml = res.errors?.length const errHtml = res.errors?.length
? `<details class="mt-2"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary> ? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${UI.escape(res.errors.join('\n'))}</pre></details>` <pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${UI.escape(res.errors.join('\n'))}</pre></details>`
: ''; : '';
@ -1925,7 +1925,7 @@ window.Page_diary = (() => {
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md); <div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)"> padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong> <strong>${res.imported} Einträge importiert</strong>
${res.skipped ? `<span class="text-sm-muted"> · ${res.skipped} übersprungen</span>` : ''} ${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
${errHtml} ${errHtml}
</div>`; </div>`;
resultEl.style.display = 'block'; resultEl.style.display = 'block';

View file

@ -84,7 +84,7 @@ window.Page_dog_profile = (() => {
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4);padding:4px"> <div style="position:relative;display:inline-block;margin-bottom:var(--space-4);padding:4px">
${dog.foto_url ${dog.foto_url
? `<div class="dp-avatar-ring"> ? `<div class="dp-avatar-ring">
<img src="${dog.foto_url}" alt="${UI.escape(dog.name)}" class="dp-avatar-img" <img src="${dog.foto_url}" alt="${_esc(dog.name)}" class="dp-avatar-img"
style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}%,${dog.foto_offset_y||0}%)"> style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}%,${dog.foto_offset_y||0}%)">
</div>` </div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`} : `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
@ -95,28 +95,28 @@ window.Page_dog_profile = (() => {
<!-- Name + Rasse --> <!-- Name + Rasse -->
<h2 style="font-size:var(--text-2xl);font-weight:700; <h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${UI.escape(dog.name)}</h2> color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse ${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${UI.escape(dog.rasse)}</p>` ? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`} : `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) --> <!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" class="mb-4"></div> <div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<!-- Info-Grid --> <!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3); <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
margin-bottom:var(--space-5);text-align:left"> margin-bottom:var(--space-5);text-align:left">
${geburtstag ? ` ${geburtstag ? `
<div class="card p-3"> <div class="card" style="padding:var(--space-3)">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div> <div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div> <div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_calcAlter(dog.geburtstag)} ${_calcAlter(dog.geburtstag)}
</div> </div>
</div> </div>
` : ''} ` : ''}
${dog.geschlecht ? ` ${dog.geschlecht ? `
<div class="card p-3"> <div class="card" style="padding:var(--space-3)">
<div class="dp-info-label">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div> <div class="dp-info-label">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
<div style="font-weight:500;font-size:var(--text-sm)"> <div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'} ${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
@ -130,19 +130,19 @@ window.Page_dog_profile = (() => {
</div> </div>
` : ''} ` : ''}
${dog.widerrist_cm ? ` ${dog.widerrist_cm ? `
<div class="card p-3"> <div class="card" style="padding:var(--space-3)">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#ruler"></use></svg> Widerrist</div> <div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#ruler"></use></svg> Widerrist</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div> <div style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div>
</div> </div>
` : ''} ` : ''}
<div class="card p-3"> <div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary); <div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"> margin-bottom:2px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder
</div> </div>
${dog.chip_nr ${dog.chip_nr
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${UI.escape(dog.chip_nr)}</div>` ? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${_esc(dog.chip_nr)}</div>`
: `<div class="text-xs-muted">nicht eingetragen : `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">nicht eingetragen
<button class="btn btn-link btn-sm" id="dp-chip-edit-btn" <button class="btn btn-link btn-sm" id="dp-chip-edit-btn"
style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button> style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button>
</div>` </div>`
@ -153,7 +153,7 @@ window.Page_dog_profile = (() => {
${dog.bio ? ` ${dog.bio ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-5);text-align:left"> <div class="card" style="padding:var(--space-4);margin-bottom:var(--space-5);text-align:left">
<p style="margin:0;color:var(--c-text-secondary);font-style:italic;line-height:1.6"> <p style="margin:0;color:var(--c-text-secondary);font-style:italic;line-height:1.6">
"${UI.escape(dog.bio)}" "${_esc(dog.bio)}"
</p> </p>
</div> </div>
` : ''} ` : ''}
@ -230,12 +230,12 @@ window.Page_dog_profile = (() => {
<div class="card" style="margin-bottom:var(--space-5)"> <div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)"> <div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600">Sitter-Zugang</div> <div style="font-weight:600">Sitter-Zugang</div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gib einem Freund temporären Schreibzugang für diesen Hund. Gib einem Freund temporären Schreibzugang für diesen Hund.
Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen. Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen.
</div> </div>
</div> </div>
<div id="dp-sitting-access" class="p-4">Lade</div> <div id="dp-sitting-access" style="padding:var(--space-4)">Lade</div>
</div> </div>
` : ''} ` : ''}
`; `;
@ -335,12 +335,12 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"> <svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use> <use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
</svg> </svg>
${UI.escape(skill.exercise_name)} ${_esc(skill.exercise_name)}
</span>`; </span>`;
}; };
const sitztBlock = sitzt.length ? ` const sitztBlock = sitzt.length ? `
<div class="mb-3"> <div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold); <div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2); color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Sitzt</div> text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
@ -360,7 +360,7 @@ window.Page_dog_profile = (() => {
</div>` : ''; </div>` : '';
el.innerHTML = ` el.innerHTML = `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true"> <svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use> <use href="/icons/phosphor.svg#list-checks"></use>
@ -409,11 +409,11 @@ window.Page_dog_profile = (() => {
: ''; : '';
el.innerHTML = ` el.innerHTML = `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span style="font-size:1.1em">🛁</span> <span style="font-size:1.1em">🛁</span>
<span style="font-size:var(--text-sm);font-weight:600"> <span style="font-size:var(--text-sm);font-weight:600">
Pflegetipps${data.rasse_name ? ` für ${UI.escape(data.rasse_name)}` : ''} Pflegetipps${data.rasse_name ? ` für ${_esc(data.rasse_name)}` : ''}
</span> </span>
</div> </div>
@ -426,24 +426,24 @@ window.Page_dog_profile = (() => {
${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'} ${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'}
</div> </div>
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:4px"> <div style="font-weight:600;font-size:var(--text-sm);margin-bottom:4px">
${kat_icons[t.kategorie]||_ph('paw-print')} ${UI.escape(t.titel)} ${kat_icons[t.kategorie]||_ph('paw-print')} ${_esc(t.titel)}
</div> </div>
<div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px; <div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px;
line-height:1.5">${UI.escape(t.beschreibung||'')}</div> line-height:1.5">${_esc(t.beschreibung||'')}</div>
${t.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted)"> ${t.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted)">
🔄 ${UI.escape(t.haeufigkeit)}</div>` : ''} 🔄 ${_esc(t.haeufigkeit)}</div>` : ''}
${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)"> ${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
🛒 ${UI.escape(t.materialien)}</div>` : ''} 🛒 ${_esc(t.materialien)}</div>` : ''}
${t.schritte?.length ? ` ${t.schritte?.length ? `
<details style="margin-top:8px"> <details style="margin-top:8px">
<summary style="font-size:12px;cursor:pointer;color:var(--c-primary); <summary style="font-size:12px;cursor:pointer;color:var(--c-primary);
font-weight:600">Anleitung anzeigen</summary> font-weight:600">Anleitung anzeigen</summary>
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px; <ol style="margin:8px 0 0 16px;padding:0;font-size:12px;
color:var(--c-text);line-height:1.6"> color:var(--c-text);line-height:1.6">
${t.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')} ${t.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
</ol> </ol>
${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa; ${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${UI.escape(t.tipp)}</div>` : ''} font-style:italic">💜 ${_esc(t.tipp)}</div>` : ''}
</details>` : ''} </details>` : ''}
</div>` : ''} </div>` : ''}
@ -457,29 +457,29 @@ window.Page_dog_profile = (() => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat); const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : ''; const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
return ` return `
<div class="mb-3"> <div style="margin-bottom:var(--space-3)">
<div style="font-size:11px;font-weight:700;color:var(--c-text-muted); <div style="font-size:11px;font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center"> text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center">
${kat_icons[kat]||_ph('paw-print')} ${UI.escape(kat)}${katBadge}</div> ${kat_icons[kat]||_ph('paw-print')} ${_esc(kat)}${katBadge}</div>
${katTipps.map(tip => ` ${katTipps.map(tip => `
<details style="background:var(--c-surface-2);border-radius:8px; <details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px"> padding:10px;margin-bottom:6px">
<summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer; <summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer;
list-style:none;display:flex;justify-content:space-between; list-style:none;display:flex;justify-content:space-between;
align-items:center"> align-items:center">
${UI.escape(tip.titel)} ${_esc(tip.titel)}
${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''} ${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''}
</summary> </summary>
<div style="margin-top:8px;font-size:12px;color:var(--c-text-secondary); <div style="margin-top:8px;font-size:12px;color:var(--c-text-secondary);
line-height:1.5">${UI.escape(tip.beschreibung||'')}</div> line-height:1.5">${_esc(tip.beschreibung||'')}</div>
${tip.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted); ${tip.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted);
margin-top:4px">🔄 ${UI.escape(tip.haeufigkeit)}</div>` : ''} margin-top:4px">🔄 ${_esc(tip.haeufigkeit)}</div>` : ''}
${tip.schritte?.length ? ` ${tip.schritte?.length ? `
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;line-height:1.6"> <ol style="margin:8px 0 0 16px;padding:0;font-size:12px;line-height:1.6">
${tip.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')} ${tip.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
</ol>` : ''} </ol>` : ''}
${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa; ${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${UI.escape(tip.tipp)}</div>` : ''} font-style:italic">💜 ${_esc(tip.tipp)}</div>` : ''}
</details>`).join('')} </details>`).join('')}
</div>`; </div>`;
}).join('')} }).join('')}
@ -499,6 +499,12 @@ window.Page_dog_profile = (() => {
}); });
} }
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// SITTER-ZUGANG // SITTER-ZUGANG
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -521,8 +527,8 @@ window.Page_dog_profile = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);margin-bottom:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);margin-bottom:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)"> <div style="flex:1;font-size:var(--text-sm)">
<strong>${UI.escape(s.sitter_name)}</strong> <strong>${_esc(s.sitter_name)}</strong>
<span class="text-muted"> · bis ${UI.escape(s.valid_until)}</span> <span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
</div> </div>
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}" <button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
style="color:var(--c-danger);padding:0"> style="color:var(--c-danger);padding:0">
@ -532,7 +538,7 @@ window.Page_dog_profile = (() => {
} }
const friendOptions = friends.length const friendOptions = friends.length
? friends.map(f => `<option value="${f.friend_id}">${UI.escape(f.friend_name)}</option>`).join('') ? friends.map(f => `<option value="${f.friend_id}">${_esc(f.friend_name)}</option>`).join('')
: '<option value="" disabled>Keine Freunde vorhanden</option>'; : '<option value="" disabled>Keine Freunde vorhanden</option>';
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@ -541,26 +547,26 @@ window.Page_dog_profile = (() => {
wrap.innerHTML = ` wrap.innerHTML = `
${activeHtml} ${activeHtml}
${friends.length ? ` ${friends.length ? `
<div class="mt-3"> <div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted); <div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div> margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div>
<div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2); <div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2);
align-items:end"> align-items:end">
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="form-label text-xs">Freund</label> <label class="form-label" style="font-size:var(--text-xs)">Freund</label>
<select class="form-control form-control-sm" id="sa-friend-select"> <select class="form-control form-control-sm" id="sa-friend-select">
<option value="">Freund wählen</option> <option value="">Freund wählen</option>
${friendOptions} ${friendOptions}
</select> </select>
</div> </div>
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="form-label text-xs">Gültig bis</label> <label class="form-label" style="font-size:var(--text-xs)">Gültig bis</label>
<input class="form-control form-control-sm" type="date" id="sa-until-input" <input class="form-control form-control-sm" type="date" id="sa-until-input"
value="${defaultUntil}" min="${today}"> value="${defaultUntil}" min="${today}">
</div> </div>
</div> </div>
<button class="btn btn-primary btn-sm w-full" id="sa-grant-btn" <button class="btn btn-primary btn-sm w-full" id="sa-grant-btn"
class="mt-2"> style="margin-top:var(--space-2)">
Zugang gewähren Zugang gewähren
</button> </button>
</div> </div>
@ -611,11 +617,11 @@ window.Page_dog_profile = (() => {
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label> <label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="chip-edit-input" class="form-control" type="text" <input id="chip-edit-input" class="form-control" type="text"
value="${UI.escape(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20"> value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`, </div>`,
footer: ` footer: `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
<button class="btn btn-primary" id="chip-edit-save-btn" class="w-full">Speichern</button> <button class="btn btn-primary" id="chip-edit-save-btn" style="width:100%">Speichern</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button> <button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>`, </div>`,
}); });
@ -660,20 +666,20 @@ window.Page_dog_profile = (() => {
<div class="photo-editor-controls"> <div class="photo-editor-controls">
<label class="form-label">Zoom</label> <label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}" <input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
class="w-full"> style="width:100%">
</div> </div>
` : ''} ` : ''}
<label class="btn btn-secondary" style="cursor:pointer"> <label class="btn btn-secondary" style="cursor:pointer">
${UI.icon('upload-simple')} Neues Foto wählen ${UI.icon('upload-simple')} Neues Foto wählen
<input type="file" id="pe-file-input" accept="image/*" class="hidden"> <input type="file" id="pe-file-input" accept="image/*" style="display:none">
</label> </label>
</div> </div>
`; `;
const footer = ` const footer = `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''} ${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''} ${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button> <button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div> </div>
@ -837,15 +843,15 @@ window.Page_dog_profile = (() => {
<!-- Header --> <!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${dog.foto_url ${dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover; ? `<img src="${_esc(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,0.6);flex-shrink:0">` border:2px solid rgba(196,132,58,0.6);flex-shrink:0">`
: `<div style="width:52px;height:52px;border-radius:50%;background:rgba(196,132,58,0.2); : `<div style="width:52px;height:52px;border-radius:50%;background:rgba(196,132,58,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.6rem; display:flex;align-items:center;justify-content:center;font-size:1.6rem;
flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`} flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div> <div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${UI.escape(dog.name)}</div> <div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${_esc(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${UI.escape(metaLine)}</div>` : ''} ${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${_esc(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${UI.escape(wohnort)}</div>` : ''} ${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${_esc(wohnort)}</div>` : ''}
</div> </div>
</div> </div>
@ -854,13 +860,13 @@ window.Page_dog_profile = (() => {
<!-- Owner + QR --> <!-- Owner + QR -->
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px"> <div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
${ownerName ? `<div style="font-size:0.7rem;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Besitzer</div> ${ownerName ? `<div style="font-size:0.7rem;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Besitzer</div>
<div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${UI.escape(ownerName)}</div>` : ''} <div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${_esc(ownerName)}</div>` : ''}
<div style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div> <div style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div>
</div> </div>
<div style="flex-shrink:0;text-align:center"> <div style="flex-shrink:0;text-align:center">
<img id="dp-vcard-qr" src="${UI.escape(qrUrl)}" <img id="dp-vcard-qr" src="${_esc(qrUrl)}"
style="width:80px;height:80px;border-radius:10px;display:block" style="width:80px;height:80px;border-radius:10px;display:block"
alt="QR-Code"> alt="QR-Code">
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div> <div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div>
@ -872,9 +878,9 @@ window.Page_dog_profile = (() => {
UI.modal.open({ UI.modal.open({
title: 'Visitenkarte', title: 'Visitenkarte',
body: ` body: `
<div class="mb-4">${cardHtml}</div> <div style="margin-bottom:var(--space-4)">${cardHtml}</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);text-align:center;margin-bottom:0"> <p style="font-size:var(--text-xs);color:var(--c-text-secondary);text-align:center;margin-bottom:0">
QR-Code auf NFC-Tag oder Anhänger kleben jeder kann das Profil von ${UI.escape(dog.name)} sofort öffnen. QR-Code auf NFC-Tag oder Anhänger kleben jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
</p> </p>
`, `,
footer: ` footer: `
@ -929,7 +935,7 @@ window.Page_dog_profile = (() => {
async function _showShareModal(dog) { async function _showShareModal(dog) {
UI.modal.open({ UI.modal.open({
title: `${UI.escape(dog.name)} teilen`, title: `${_esc(dog.name)} teilen`,
body: ` body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Erstelle einen Einladungslink, den du per WhatsApp, Signal oder E-Mail teilen kannst. Erstelle einen Einladungslink, den du per WhatsApp, Signal oder E-Mail teilen kannst.
@ -946,7 +952,7 @@ window.Page_dog_profile = (() => {
<label class="form-label">Einladungslink</label> <label class="form-label">Einladungslink</label>
<div style="display:flex;gap:var(--space-2);align-items:center"> <div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" id="share-link-input" type="text" readonly <input class="form-control" id="share-link-input" type="text" readonly
class="text-xs"> style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="share-link-copy"> <button class="btn btn-secondary btn-sm" id="share-link-copy">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button> </button>
@ -955,7 +961,7 @@ window.Page_dog_profile = (() => {
Dieser Link kann einmalig angenommen werden. Dieser Link kann einmalig angenommen werden.
</p> </p>
</div> </div>
<div id="share-list-wrap" class="mt-4"></div>`, <div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`,
footer: ` footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button> <button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="share-create-btn">Link erstellen</button>`, <button class="btn btn-primary" id="share-create-btn">Link erstellen</button>`,
@ -1003,8 +1009,8 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)"> <div style="flex:1;font-size:var(--text-sm)">
${s.shared_with_name ${s.shared_with_name
? `<strong>${UI.escape(s.shared_with_name)}</strong> · ${s.role}` ? `<strong>${_esc(s.shared_with_name)}</strong> · ${s.role}`
: `<em class="text-muted">Ausstehend</em> · ${s.role}`} : `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`}
</div> </div>
<button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}" <button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}"
style="color:var(--c-danger);padding:0"> style="color:var(--c-danger);padding:0">
@ -1050,7 +1056,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true), body: _formHTML(null, true),
footer: ` footer: `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary w-full">${UI.icon('dog')} Hund anlegen</button> <button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">${UI.icon('dog')} Hund anlegen</button>
<button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
</div> </div>
`, `,
@ -1067,8 +1073,8 @@ window.Page_dog_profile = (() => {
body: _formHTML(dog, true), body: _formHTML(dog, true),
footer: ` footer: `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary w-full">Speichern</button> <button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
<div class="flex-gap-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" <button type="button" id="dp-gedenken-btn"
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -1095,19 +1101,19 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Name *</label> <label class="form-label">Name *</label>
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
value="${UI.escape(dog?.name || '')}" value="${_esc(dog?.name || '')}"
placeholder="z. B. Ban Yaro" required> placeholder="z. B. Ban Yaro" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Rasse Rasse
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Verknüpfe deine Rasse mit unserem Wiki für personalisierte Pflegetipps.')} ${UI.help('Verknüpfe deine Rasse mit unserem Wiki für personalisierte Pflegetipps.')}
</label> </label>
<input class="form-control" type="text" name="rasse" <input class="form-control" type="text" name="rasse"
id="dp-rasse-input" id="dp-rasse-input"
value="${UI.escape(dog?.rasse || '')}" value="${_esc(dog?.rasse || '')}"
list="dp-rasse-list" list="dp-rasse-list"
autocomplete="off" autocomplete="off"
placeholder="z. B. Mischling, Golden Retriever…"> placeholder="z. B. Mischling, Golden Retriever…">
@ -1120,7 +1126,7 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtstag</label> <label class="form-label">Geburtstag</label>
<input class="form-control" type="date" name="geburtstag" <input class="form-control" type="date" name="geburtstag"
@ -1136,7 +1142,7 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Gewicht (kg)</label> <label class="form-label">Gewicht (kg)</label>
<input class="form-control" type="number" name="gewicht_kg" <input class="form-control" type="number" name="gewicht_kg"
@ -1154,14 +1160,14 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Chip-Nummer Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')} ${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label> </label>
<input class="form-control" type="text" name="chip_nr" <input class="form-control" type="text" name="chip_nr"
value="${UI.escape(dog?.chip_nr || '')}" placeholder="15-stellig"> value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
</div> </div>
<div></div> <div></div>
</div> </div>
@ -1169,7 +1175,7 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Felltyp Felltyp
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')} ${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
</label> </label>
<select class="form-control" name="fell_typ"> <select class="form-control" name="fell_typ">
@ -1186,10 +1192,10 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Bio / Steckbrief Bio / Steckbrief
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<textarea class="form-control" name="bio" rows="2" <textarea class="form-control" name="bio" rows="2"
placeholder="Kurze Beschreibung…">${UI.escape(dog?.bio || '')}</textarea> placeholder="Kurze Beschreibung…">${_esc(dog?.bio || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -1210,7 +1216,7 @@ window.Page_dog_profile = (() => {
display:${dog?.foto_url ? 'block' : 'none'}"> display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0"> <label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
<input type="file" name="foto" accept="image/*" class="hidden" <input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto"> id="dp-form-foto">
</label> </label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn" <button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
@ -1219,7 +1225,7 @@ window.Page_dog_profile = (() => {
Rasse erkennen Rasse erkennen
</button> </button>
<input type="file" accept="image/jpeg,image/png,image/webp" <input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" class="hidden"> id="dp-rasse-foto-input" style="display:none">
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px"> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen Foto hochladen um die Rasse per KI zu erkennen
@ -1467,11 +1473,11 @@ window.Page_dog_profile = (() => {
title: 'Kein Hund erkannt', title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)"> body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div> <div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p class="text-secondary"> <p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br> Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch. Bitte lade ein deutlicheres Foto hoch.
</p> </p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${UI.escape(data.hinweis)}</p>` : ''} ${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`, </div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`, footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
}); });
@ -1484,24 +1490,24 @@ window.Page_dog_profile = (() => {
return ` return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}"> <div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between"> <div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${UI.escape(r.name)}</div> <div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span> <span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div> </div>
<div class="rasse-result-bar-wrap"> <div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}" <div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div> style="width:${r.sicherheit}%"></div>
</div> </div>
${r.beschreibung ? `<div class="rasse-result-desc">${UI.escape(r.beschreibung)}</div>` : ''} ${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen" ${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
data-rasse="${UI.escape(r.name)}" class="flex-1"> data-rasse="${_esc(r.name)}" style="flex:1">
Rasse übernehmen Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen" </button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${UI.escape(r.name)}" class="flex-1"> data-rasse="${_esc(r.name)}" style="flex:1">
Diese wählen Diese wählen
</button>`} </button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki" ${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${UI.escape(r.wiki_slug)}"> data-slug="${_esc(r.wiki_slug)}">
Im Wiki Im Wiki
</button>` : ''} </button>` : ''}
</div> </div>
@ -1515,7 +1521,7 @@ window.Page_dog_profile = (() => {
<div style="padding-bottom:var(--space-2)"> <div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md); ${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm); padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${UI.escape(data.hinweis)}</div>` : ''} color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml} ${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2); <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center"> text-align:center">
@ -1576,13 +1582,18 @@ window.Page_dog_profile = (() => {
: `${j} Jahr${j !== 1 ? 'e' : ''} alt`; : `${j} Jahr${j !== 1 ? 'e' : ''} alt`;
} }
function _esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// HUNDEPASS // HUNDEPASS
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _showPassportModal(dog) { async function _showPassportModal(dog) {
UI.modal.open({ UI.modal.open({
title: `Hundepass — ${UI.escape(dog.name)}`, title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px"> body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)"> <div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true"> <svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
@ -1625,7 +1636,7 @@ window.Page_dog_profile = (() => {
try { try {
data = await API.get(`/passport/${dog.id}`); data = await API.get(`/passport/${dog.id}`);
} catch (e) { } catch (e) {
wrap.innerHTML = `<p class="text-danger">Fehler beim Laden: ${UI.escape(e.message)}</p>`; wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
return; return;
} }
@ -1655,25 +1666,25 @@ window.Page_dog_profile = (() => {
Bearbeiten Bearbeiten
</button> </button>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div> <div>
<div class="text-xs-secondary">Blutgruppe</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500"> <div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${UI.escape(meta.blutgruppe) || '<span class="text-muted">nicht eingetragen</span>'} ${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
</div> </div>
</div> </div>
<div> <div>
<div class="text-xs-secondary">Allergien</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" class="text-sm"> <div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${UI.escape(meta.allergien) || '<span class="text-muted">keine</span>'} ${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
</div> </div>
</div> </div>
</div> </div>
${meta.besonderheiten ? ` ${meta.besonderheiten ? `
<div class="mt-3"> <div style="margin-top:var(--space-3)">
<div class="text-xs-secondary">Besonderheiten</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" class="text-sm"> <div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${UI.escape(meta.besonderheiten)} ${_esc(meta.besonderheiten)}
</div> </div>
</div>` : ''} </div>` : ''}
</div> </div>
@ -1697,13 +1708,13 @@ window.Page_dog_profile = (() => {
: vaccs.map(v => ` : vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}" <div class="pp-vacc-row" data-id="${v.id}"
class="pp-data-row"> class="pp-data-row">
<div class="flex-1"> <div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(v.krankheit)}</div> <div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)} Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''} ${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${UI.escape(v.tierarzt)}` : ''} ${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${UI.escape(v.charge_nr)}` : ''} ${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div> </div>
</div> </div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}" <button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
@ -1716,7 +1727,7 @@ window.Page_dog_profile = (() => {
</div> </div>
<!-- Medikamente --> <!-- Medikamente -->
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between; <div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)"> margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)"> <span style="font-weight:700;font-size:var(--text-sm)">
@ -1734,13 +1745,13 @@ window.Page_dog_profile = (() => {
: meds.map(m => ` : meds.map(m => `
<div class="pp-med-row" data-id="${m.id}" <div class="pp-med-row" data-id="${m.id}"
class="pp-data-row"> class="pp-data-row">
<div class="flex-1"> <div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(m.name)}</div> <div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${UI.escape(m.dosierung)} · ` : ''} ${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''} ${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''} ${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${UI.escape(m.notiz)}` : ''} ${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div> </div>
</div> </div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}" <button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
@ -1802,17 +1813,17 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Blutgruppe</label> <label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text" <input id="pp-meta-bg" class="form-control" type="text"
value="${UI.escape(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv"> value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Allergien</label> <label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2" <textarea id="pp-meta-al" class="form-control" rows="2"
placeholder="z. B. Hühnchen, Flohspeichel">${UI.escape(current.allergien || '')}</textarea> placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Besonderheiten</label> <label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2" <textarea id="pp-meta-be" class="form-control" rows="2"
placeholder="z. B. Herzprobleme, Angstpatient">${UI.escape(current.besonderheiten || '')}</textarea> placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
</div>`, </div>`,
footer: ` footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end"> <div style="display:flex;gap:var(--space-2);justify-content:flex-end">
@ -1860,7 +1871,7 @@ window.Page_dog_profile = (() => {
<option value="DHPP (Kombi)"> <option value="DHPP (Kombi)">
</datalist> </datalist>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Datum *</label> <label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}"> <input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
@ -1927,13 +1938,13 @@ window.Page_dog_profile = (() => {
<input id="pp-med-dosierung" class="form-control" type="text" <input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg"> placeholder="z. B. 1× täglich, 5 mg">
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Von</label> <label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}"> <input id="pp-med-von" class="form-control" type="date" value="${today}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Bis <span class="text-muted">(leer = dauerhaft)</span></label> <label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
<input id="pp-med-bis" class="form-control" type="date"> <input id="pp-med-bis" class="form-control" type="date">
</div> </div>
</div> </div>
@ -1990,7 +2001,7 @@ window.Page_dog_profile = (() => {
</p> </p>
<div style="display:flex;gap:var(--space-2);align-items:center"> <div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly <input id="pp-sharelink-input" class="form-control" type="text" readonly
value="${UI.escape(url)}" class="text-xs"> value="${_esc(url)}" style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0"> <button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button> </button>
@ -2026,7 +2037,7 @@ window.Page_dog_profile = (() => {
return; return;
} }
const name = UI.escape(data.dog_name); const name = _esc(data.dog_name);
const km = data.gesamt_km || 0; const km = data.gesamt_km || 0;
const konfetti = km > 100; const konfetti = km > 100;
@ -2068,8 +2079,8 @@ window.Page_dog_profile = (() => {
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div> <div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div>
${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''} ${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''}
${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''} ${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''}
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${UI.escape(data.lieblings_monat)}</div>` : ''} ${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${_esc(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${UI.escape(aktivitaet)}</div>` : ''} ${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</div>` : ''}
`), `),
_card(` _card(`
<div style="font-size:2rem">🌡</div> <div style="font-size:2rem">🌡</div>
@ -2115,8 +2126,8 @@ window.Page_dog_profile = (() => {
</div> </div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative"> <div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div> <div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button> <button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button> <button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</div> </div>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div> <div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
`; `;
@ -2288,7 +2299,7 @@ window.Page_dog_profile = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _showTimelineModal(dog) { async function _showTimelineModal(dog) {
UI.modal.open({ UI.modal.open({
title: `Lebens-Timeline — ${UI.escape(dog.name)}`, title: `Lebens-Timeline — ${_esc(dog.name)}`,
body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)"> body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true"> <svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use> <use href="/icons/phosphor.svg#spinner-gap"></use>
@ -2303,7 +2314,7 @@ window.Page_dog_profile = (() => {
data = await API.get(`/dogs/${dog.id}/timeline`); data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) { } catch (e) {
const b = document.getElementById('dp-timeline-body'); const b = document.getElementById('dp-timeline-body');
if (b) b.innerHTML = `<p class="text-danger">Fehler: ${UI.escape(e.message)}</p>`; if (b) b.innerHTML = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
return; return;
} }
@ -2340,14 +2351,14 @@ window.Page_dog_profile = (() => {
for (const ev of events) { for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null; const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) { if (year && year !== lastYear) {
html += `<div class="tl-year">${UI.escape(year)}</div>`; html += `<div class="tl-year">${_esc(year)}</div>`;
lastYear = year; lastYear = year;
} }
const kat = _KAT[ev.kategorie] || _KAT.tagebuch; const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
const big = ev.is_milestone; const big = ev.is_milestone;
let label = UI.escape(ev.titel); let label = _esc(ev.titel);
if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`; if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`; if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`; if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`;
@ -2365,13 +2376,13 @@ window.Page_dog_profile = (() => {
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div> box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card"> <div class="tl-card">
${big && ev.foto_url ? ` ${big && ev.foto_url ? `
<div class="tl-foto" style="background-image:url(${UI.escape(ev.foto_url)})"></div>` : ''} <div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
<div class="tl-meta"> <div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}"> <span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"> <svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use> <use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg> </svg>
${UI.escape(kat.label)} ${_esc(kat.label)}
</span> </span>
<span class="tl-date">${_fmtDate(ev.datum)}</span> <span class="tl-date">${_fmtDate(ev.datum)}</span>
</div> </div>
@ -2440,8 +2451,8 @@ window.Page_dog_profile = (() => {
if (!data || data.count === 0) return; if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || ''; const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1 const label = data.count === 1
? `1 anderer ${UI.escape(hauptRasse)}-Halter in der App` ? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${UI.escape(hauptRasse)}-Halter in der App`; : `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = ` el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn"> <button class="breed-community-chip" id="dp-breed-chip-btn">
@ -2487,7 +2498,7 @@ window.Page_dog_profile = (() => {
</form>`, </form>`,
footer: ` footer: `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary w-full"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Gedenkseite erstellen Gedenkseite erstellen
</button> </button>
@ -2539,22 +2550,22 @@ window.Page_dog_profile = (() => {
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div class="text-xs-secondary">km zusammen</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
</div>` : ''} </div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div class="text-xs-secondary">Tagebucheinträge</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
</div>` : ''} </div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
<div class="text-xs-secondary">Fotos</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
</div>` : ''} </div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div class="text-xs-secondary">gemeinsame Tage</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
</div>` : ''} </div>` : ''}
</div>`; </div>`;
@ -2585,8 +2596,8 @@ window.Page_dog_profile = (() => {
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos) Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div> </div>
</div> </div>
<div id="gedenk-ki-wrap" class="mt-4"> <div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
<button id="gedenk-ki-btn" class="btn btn-secondary w-full"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen Persönlichen Abschiedstext erstellen
</button> </button>

View file

@ -21,6 +21,14 @@ window.Page_ernaehrung = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Escape helper // Escape helper
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// LIFECYCLE // LIFECYCLE
@ -148,17 +156,17 @@ window.Page_ernaehrung = (() => {
<div class="ern-field"> <div class="ern-field">
<label> Gewicht (kg)</label> <label> Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100" <input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
value="${UI.escape(gewichtDefault)}" placeholder="15"> value="${_esc(gewichtDefault)}" placeholder="15">
</div> </div>
<div class="ern-field"> <div class="ern-field">
<label>🎂 Alter (Jahre)</label> <label>🎂 Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25" <input id="ern-alter" type="number" step="0.5" min="0" max="25"
value="${UI.escape(alterDefault)}" placeholder="3"> value="${_esc(alterDefault)}" placeholder="3">
</div> </div>
</div> </div>
<!-- Aktivität als Pill-Buttons --> <!-- Aktivität als Pill-Buttons -->
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<div class="ern-section-label">🏃 Aktivität</div> <div class="ern-section-label">🏃 Aktivität</div>
<div class="ern-pill-group"> <div class="ern-pill-group">
<button class="ern-pill" data-akt="gering">🛋 Gemütlich</button> <button class="ern-pill" data-akt="gering">🛋 Gemütlich</button>
@ -201,7 +209,7 @@ window.Page_ernaehrung = (() => {
<div class="by-form-group" style="margin:0"> <div class="by-form-group" style="margin:0">
<label class="by-label">Marke / Produkt</label> <label class="by-label">Marke / Produkt</label>
<input id="ern-prof-marke" type="text" class="by-input" <input id="ern-prof-marke" type="text" class="by-input"
value="${UI.escape(_profil.marke)}" placeholder="z. B. Royal Canin"> value="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
</div> </div>
<div class="by-form-group" style="margin:0"> <div class="by-form-group" style="margin:0">
<label class="by-label">Portionen pro Tag</label> <label class="by-label">Portionen pro Tag</label>
@ -211,7 +219,7 @@ window.Page_ernaehrung = (() => {
<div class="by-form-group" style="margin:0"> <div class="by-form-group" style="margin:0">
<label class="by-label">Notizen</label> <label class="by-label">Notizen</label>
<textarea id="ern-prof-notizen" class="by-input" rows="2" <textarea id="ern-prof-notizen" class="by-input" rows="2"
placeholder="Besonderheiten, Allergien...">${UI.escape(_profil.notizen)}</textarea> placeholder="Besonderheiten, Allergien...">${_esc(_profil.notizen)}</textarea>
</div> </div>
<button class="btn btn-secondary" id="ern-prof-save-btn"> <button class="btn btn-secondary" id="ern-prof-save-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
@ -280,13 +288,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md); <div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)"> padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div> <div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~350 kcal/100g) (~350 kcal/100g)
</div> </div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px"> <div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${trocken} g / Tag ${trocken} g / Tag
</div> </div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends = ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
</div> </div>
</div> </div>
@ -294,13 +302,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md); <div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)"> padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div> <div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~85 kcal/100g) (~85 kcal/100g)
</div> </div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px"> <div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${nass} g / Tag ${nass} g / Tag
</div> </div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends = ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
</div> </div>
</div> </div>
@ -308,13 +316,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md); <div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)"> padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥩 BARF</div> <div style="font-weight:600;margin-bottom:4px">🥩 BARF</div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~150 kcal/100g) (~150 kcal/100g)
</div> </div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px"> <div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${barf} g / Tag ${barf} g / Tag
</div> </div>
<div class="text-sm-secondary"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends = ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
</div> </div>
</div> </div>
@ -474,8 +482,8 @@ window.Page_ernaehrung = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:1.4rem">${item.emoji}</span> <span style="font-size:1.4rem">${item.emoji}</span>
<div> <div>
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${UI.escape(item.name)}</div> <div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(item.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-danger)">${UI.escape(item.grund)}</div> <div style="font-size:var(--text-xs);color:var(--c-danger)">${_esc(item.grund)}</div>
</div> </div>
</div> </div>
</div> </div>
@ -503,7 +511,7 @@ window.Page_ernaehrung = (() => {
border:1px solid var(--c-border)"> border:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
Der KI-Futterberater beantwortet Ernährungsfragen für Der KI-Futterberater beantwortet Ernährungsfragen für
<strong>${UI.escape(dog?.name || 'deinen Hund')}</strong>. <strong>${_esc(dog?.name || 'deinen Hund')}</strong>.
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen. Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
</div> </div>
@ -516,8 +524,8 @@ window.Page_ernaehrung = (() => {
'Welche Leckerlis sind gesund?', 'Welche Leckerlis sind gesund?',
].map(q => ` ].map(q => `
<button class="btn btn-sm btn-secondary ern-ki-vorschlag" <button class="btn btn-sm btn-secondary ern-ki-vorschlag"
data-q="${UI.escape(q)}" data-q="${_esc(q)}"
class="text-xs">${UI.escape(q)}</button> style="font-size:var(--text-xs)">${_esc(q)}</button>
`).join('')} `).join('')}
</div> </div>
@ -525,7 +533,7 @@ window.Page_ernaehrung = (() => {
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div> <div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
<!-- Eingabe --> <!-- Eingabe -->
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
<textarea id="ern-ki-frage" class="by-input" rows="2" <textarea id="ern-ki-frage" class="by-input" rows="2"
placeholder="Deine Frage zur Ernährung..." placeholder="Deine Frage zur Ernährung..."
style="flex:1;resize:vertical"></textarea> style="flex:1;resize:vertical"></textarea>
@ -569,7 +577,7 @@ window.Page_ernaehrung = (() => {
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)"> <div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)">
<div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md); <div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)"> padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)">
${UI.escape(frage)} ${_esc(frage)}
</div> </div>
</div> </div>
`); `);
@ -578,7 +586,7 @@ window.Page_ernaehrung = (() => {
// KI-Antwort Placeholder // KI-Antwort Placeholder
const placeholderId = `ern-ki-placeholder-${Date.now()}`; const placeholderId = `ern-ki-placeholder-${Date.now()}`;
chatEl.insertAdjacentHTML('beforeend', ` chatEl.insertAdjacentHTML('beforeend', `
<div id="${placeholderId}" class="mb-3"> <div id="${placeholderId}" style="margin-bottom:var(--space-3)">
<div style="background:var(--c-surface);border:1px solid var(--c-border); <div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3); border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-muted)"> font-size:var(--text-sm);color:var(--c-text-muted)">
@ -609,7 +617,7 @@ window.Page_ernaehrung = (() => {
} }
} }
const antwortHtml = UI.escape(antwort) const antwortHtml = _esc(antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">') .replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">')
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
@ -738,7 +746,7 @@ window.Page_ernaehrung = (() => {
const dl = document.getElementById('vert-futter-datalist'); const dl = document.getElementById('vert-futter-datalist');
if (!dl) return; if (!dl) return;
const names = [...new Set((list || []).map(e => e.futter_name))]; const names = [...new Set((list || []).map(e => e.futter_name))];
dl.innerHTML = names.map(n => `<option value="${UI.escape(n)}">`).join(''); dl.innerHTML = names.map(n => `<option value="${_esc(n)}">`).join('');
}).catch(() => {}); }).catch(() => {});
setTimeout(() => { setTimeout(() => {
@ -897,7 +905,7 @@ window.Page_ernaehrung = (() => {
try { try {
data = await API.dogs.futterAnalyse(dog.id); data = await API.dogs.futterAnalyse(dog.id);
} catch (_) { } catch (_) {
analyseEl.innerHTML = `<p class="text-sm-muted">Analyse nicht verfügbar.</p>`; analyseEl.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Analyse nicht verfügbar.</p>`;
return; return;
} }
@ -942,7 +950,7 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-warning,#f59e0b);margin-top:1px"> <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> <use href="/icons/phosphor.svg#warning-circle"></use>
</svg> </svg>
<span>${UI.escape(data.hinweis)}</span> <span>${_esc(data.hinweis)}</span>
</div> </div>
` : ''; ` : '';
@ -970,7 +978,7 @@ window.Page_ernaehrung = (() => {
return `<span style="font-size:10px;font-weight:600;padding:2px 6px; return `<span style="font-size:10px;font-weight:600;padding:2px 6px;
border-radius:999px;border:1px solid ${chipColor}; border-radius:999px;border:1px solid ${chipColor};
color:${chipColor};white-space:nowrap"> color:${chipColor};white-space:nowrap">
${UI.escape(KAT_LABELS[kat] || kat)} ×${cnt} ${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
</span>`; </span>`;
}).join(''); }).join('');
return ` return `
@ -980,17 +988,17 @@ window.Page_ernaehrung = (() => {
<div style="min-width:0;flex:1"> <div style="min-width:0;flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text); <div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(f.name)} ${_esc(f.name)}
</div> </div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.escape(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''} ${_esc(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
${f.status !== 'neu' ? `&middot; <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''} ${f.status !== 'neu' ? `&middot; <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''}
</div> </div>
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''} ${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
</div> </div>
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700; <span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
color:${cfg.color};white-space:nowrap"> color:${cfg.color};white-space:nowrap">
${UI.escape(cfg.label)} ${_esc(cfg.label)}
</span> </span>
</div> </div>
`; `;
@ -1076,10 +1084,10 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)"> <svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bowl-food"></use> <use href="/icons/phosphor.svg#bowl-food"></use>
</svg> </svg>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(item.futter_name)}</div> <div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.futter_name)}</div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)} ${_esc(item.datum)} ${_esc(item.uhrzeit)}
${item.menge_g ? ` &middot; ${item.menge_g} g` : ''} ${item.menge_g ? ` &middot; ${item.menge_g} g` : ''}
</div> </div>
</div> </div>
@ -1102,13 +1110,13 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}"> <svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}">
<use href="/icons/phosphor.svg#heartbeat"></use> <use href="/icons/phosphor.svg#heartbeat"></use>
</svg> </svg>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);color:${col}"> <div style="font-weight:600;font-size:var(--text-sm);color:${col}">
${UI.escape(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)} ${_esc(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
<span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span> <span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span>
</div> </div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)} ${_esc(item.datum)} ${_esc(item.uhrzeit)}
</div> </div>
</div> </div>
<button class="btn-icon vert-del-reaktion" data-id="${item.id}" <button class="btn-icon vert-del-reaktion" data-id="${item.id}"

View file

@ -32,17 +32,16 @@ window.Page_erste_hilfe = (() => {
land: 'Österreich', land: 'Österreich',
flag: 'AT', flag: 'AT',
eintraege: [ eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' }, { label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
{ label: 'VetMedUni Wien — Kleintier-Notdienst (24h)', tel: '+431250776900', display: '+43 1 25077-6900' }, { label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
], ],
}, },
{ {
land: 'Schweiz', land: 'Schweiz',
flag: 'CH', flag: 'CH',
eintraege: [ eintraege: [
{ label: 'Tox Info Suisse (in CH gratis)', tel: '145', display: '145 (in CH)' }, { label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
{ label: 'Tox Info Suisse (international)', tel: '+41442515151', display: '+41 44 251 51 51' }, { label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
{ label: 'Tierspital Zürich — Kleintier-Notfall (24h)', tel: '+41446358337', display: '+41 44 635 83 37' },
], ],
}, },
]; ];
@ -254,13 +253,13 @@ window.Page_erste_hilfe = (() => {
</div> </div>
${KATEGORIEN.map(k => ` ${KATEGORIEN.map(k => `
<div class="eh-tab-panel" id="eh-panel-${k.id}" class="hidden"> <div class="eh-tab-panel" id="eh-panel-${k.id}" style="display:none">
${k.eintraege.map((e, i) => _renderEintrag(e, k.id, i, k.color)).join('')} ${k.eintraege.map((e, i) => _renderEintrag(e, k.id, i, k.color)).join('')}
</div> </div>
`).join('')} `).join('')}
<div style="margin-top:var(--space-6);padding:var(--space-4);background:var(--c-surface-2);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6"> <div style="margin-top:var(--space-6);padding:var(--space-4);background:var(--c-surface-2);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#info"></use></svg> <svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#info"></use></svg>
Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen. Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen.
</div> </div>
</div> </div>
@ -312,7 +311,7 @@ window.Page_erste_hilfe = (() => {
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)"> <div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)">
${g.flag} · ${g.land} ${g.flag} · ${g.land}
</div> </div>
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${g.eintraege.map(renderEintrag).join('')} ${g.eintraege.map(renderEintrag).join('')}
</div> </div>
</div> </div>
@ -324,7 +323,7 @@ window.Page_erste_hilfe = (() => {
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg> <svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen Tiergiftzentralen jetzt anrufen
</div> </div>
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${gruppen} ${gruppen}
</div> </div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)"> <p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
@ -346,7 +345,7 @@ window.Page_erste_hilfe = (() => {
return ` return `
<div class="card" style="padding:0;overflow:hidden;margin-bottom:var(--space-4)"> <div class="card" style="padding:0;overflow:hidden;margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);display:flex;align-items:center;gap:var(--space-2)"> <div style="padding:var(--space-3) var(--space-4);background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#list-bullets"></use></svg> <svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
Schnellübersicht: Was tun bei Schnellübersicht: Was tun bei
</div> </div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
@ -486,7 +485,7 @@ window.Page_erste_hilfe = (() => {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0"> display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div> <div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div> <div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div> </div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button> <button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div> </div>

View file

@ -75,7 +75,7 @@ window.Page_events = (() => {
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button> <button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button> <button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div> </div>
<div class="flex-1"></div> <div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''} ${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div> </div>
@ -102,7 +102,7 @@ window.Page_events = (() => {
</div> </div>
<div class="events-list" id="ev-list"></div> <div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" class="hidden"></div> <div class="events-map" id="ev-map" style="display:none"></div>
`; `;
_container.addEventListener('click', _onClick); _container.addEventListener('click', _onClick);
@ -231,7 +231,7 @@ window.Page_events = (() => {
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}" ${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}" data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}" data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" class="text-muted" onclick="event.stopPropagation()"> title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''} ${_icon('note-pencil')}</button>` : ''}
</div> </div>
</div> </div>
@ -248,10 +248,8 @@ window.Page_events = (() => {
await UI.loadLeaflet(true); // true = mit MarkerCluster await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) { if (!_map) {
_map = await UI.map.create('ev-map', { _map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
center: [51.1657, 10.4515], zoom: 6, L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
zoomControl: true, attributionControl: false,
});
} }
// Cluster-Gruppe aufräumen und neu befüllen // Cluster-Gruppe aufräumen und neu befüllen
@ -268,8 +266,12 @@ window.Page_events = (() => {
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00'); const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }); const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.map.svgMarker mit custom HTML // Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
const html = `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg);color:#fff"><svg style="width:14px;height:14px;transform:rotate(45deg);fill:currentColor" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#${typ.icon}"></use></svg></div>`; const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg);color:#fff"><svg style="width:14px;height:14px;transform:rotate(45deg);fill:currentColor" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#${typ.icon}"></use></svg></div>`,
iconSize: [32, 32], iconAnchor: [16, 32],
});
const popup = ` const popup = `
<div style="min-width:180px"> <div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br> <strong>${UI.escape(ev.titel)}</strong><br>
@ -280,7 +282,7 @@ window.Page_events = (() => {
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a> style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div> </div>
`; `;
const m = UI.map.svgMarker(ev.lat, ev.lon, html, { size: 32, anchorY: 32 }).bindPopup(popup); const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
_clusterGroup.addLayer(m); _clusterGroup.addLayer(m);
_markers.push(m); _markers.push(m);
bounds.push([ev.lat, ev.lon]); bounds.push([ev.lat, ev.lon]);
@ -494,7 +496,7 @@ window.Page_events = (() => {
<label class="form-label">GPS-Position</label> <label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div> <div id="ev-location-picker"></div>
</div> </div>
<div class="form-group mt-3"> <div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label> <label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea> <textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div> </div>
@ -507,10 +509,10 @@ window.Page_events = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" class="w-full"> <button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" style="width:100%">
${isEdit ? 'Speichern' : 'Event erstellen'} ${isEdit ? 'Speichern' : 'Event erstellen'}
</button> </button>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''} ${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button> <button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div> </div>
@ -670,7 +672,7 @@ window.Page_events = (() => {
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0; <div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column"> padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg> <svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span> <span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px"> <button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>

View file

@ -71,7 +71,7 @@ window.Page_expenses = (() => {
if (dogs.length < 2) return ''; if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => ` const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}"> <button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}">
${d.id ? UI.icon('paw-print') : ''} ${UI.escape(d.name)} ${d.id ? UI.icon('paw-print') : ''} ${_esc(d.name)}
</button>`).join(''); </button>`).join('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`; return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
} }
@ -87,7 +87,7 @@ window.Page_expenses = (() => {
</div> </div>
${_dogSelectorHtml()} ${_dogSelectorHtml()}
<div id="exp-content"></div> <div id="exp-content"></div>
<button class="list-fab" id="exp-fab" title="Neue Ausgabe"> <button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')} ${UI.icon('plus')}
</button> </button>
`; `;
@ -162,7 +162,7 @@ window.Page_expenses = (() => {
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}"> <div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)} ${UI.icon(k.icon)}
</div> </div>
<div class="exp-kachel-betrag text-primary">${_fmt(jahr)}</div> <div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(jahr)}</div>
<div class="exp-kachel-label">${k.label}</div> <div class="exp-kachel-label">${k.label}</div>
${monatLine} ${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div> <div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
@ -283,28 +283,28 @@ window.Page_expenses = (() => {
const datum = new Date(e.datum + 'T00:00:00') const datum = new Date(e.datum + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const dogBadge = e.dog_name const dogBadge = e.dog_name
? `<span>${UI.icon('paw-print')} ${UI.escape(e.dog_name)}</span>` ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
: ''; : '';
const notiz = e.notiz const notiz = e.notiz
? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` ? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
: ''; : '';
return ` return `
<div class="list-item-card list-item-card--clickable exp-entry" data-id="${e.id}"> <div class="exp-entry" data-id="${e.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}"> <div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
${UI.icon(k.icon)} ${UI.icon(k.icon)}
</div> </div>
<div class="list-item-body"> <div class="exp-entry-body">
<div class="list-item-title">${k.label}</div> <div class="exp-entry-head">
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
${notiz} ${notiz}
<div class="list-item-meta-row">
<span>${datum}</span>
${dogBadge ? `· ${dogBadge}` : ''}
</div> </div>
</div> <div class="exp-entry-right">
<div class="list-item-amount list-item-amount--negative">${_fmt(e.betrag)}</div> <div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
<div class="list-item-actions"> <button class="exp-entry-del" data-del="${e.id}" title="Löschen"
<button class="list-item-action-btn list-item-action-btn--danger exp-entry-del" aria-label="Eintrag löschen">
data-del="${e.id}" title="Löschen" aria-label="Eintrag löschen">
${UI.icon('trash')} ${UI.icon('trash')}
</button> </button>
</div> </div>
@ -313,15 +313,15 @@ window.Page_expenses = (() => {
return ` return `
<div class="exp-month-group"> <div class="exp-month-group">
<div class="list-group-header" style="display:flex;justify-content:space-between;align-items:baseline"> <div class="exp-month-header">
<span>${titel}</span> <span class="exp-month-title">${titel}</span>
<span style="text-transform:none;font-weight:700;color:var(--c-text)">${_fmt(summe)}</span> <span class="exp-month-summe">${_fmt(summe)}</span>
</div> </div>
${rows} ${rows}
</div>`; </div>`;
}).join(''); }).join('');
el.innerHTML = `<div class="list-shell">${html}</div><div style="height:80px"></div>`; el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button) // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
el.querySelectorAll('.exp-entry').forEach(row => { el.querySelectorAll('.exp-entry').forEach(row => {
@ -372,27 +372,31 @@ window.Page_expenses = (() => {
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—'; : '—';
return ` return `
<div class="list-item-card${r.aktiv ? '' : ' list-item-card--inactive'}" data-rid="${r.id}"> <div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}">${UI.icon(k.icon)}</div> <div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="list-item-body"> <div class="exp-entry-body">
<div class="list-item-title">${k.label}</div> <div class="exp-entry-head">
${r.notiz ? `<div class="list-item-text">${UI.escape(r.notiz)}</div>` : ''} <span class="exp-entry-kat">${k.label}</span>
<div class="list-item-meta-row"> <span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
<span>${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span> ${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
· <span>${UI.icon('calendar')} ${naechste}</span> </div>
${r.dog_name ? `· <span>${UI.icon('paw-print')} ${UI.escape(r.dog_name)}</span>` : ''} ${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
${!r.aktiv ? '· <span>Pausiert</span>' : ''} <div class="exp-recurring-next">
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
</div> </div>
</div> </div>
<div class="list-item-amount list-item-amount--negative">${_fmt(r.betrag)}</div> <div class="exp-entry-right">
<div class="list-item-actions"> <div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
<button class="list-item-action-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}" <div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}"> title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')} ${UI.icon(r.aktiv ? 'pause' : 'play')}
</button> </button>
<button class="list-item-action-btn list-item-action-btn--danger exp-recurring-del" data-rid="${r.id}" <button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button> title="Löschen">${UI.icon('trash')}</button>
</div> </div>
</div>
</div>`; </div>`;
}).join(''); }).join('');
@ -403,7 +407,7 @@ window.Page_expenses = (() => {
</button> </button>
</div> </div>
${recurring.length ${recurring.length
? `<div class="list-shell">${cards}</div>` ? `<div class="exp-list">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'), : UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge', title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })} text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
@ -444,7 +448,7 @@ window.Page_expenses = (() => {
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join(''); ].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
const dogOptions = (_appState.dogs || []).map(d => const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${UI.escape(d.name)}</option>` `<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
).join(''); ).join('');
const body = ` const body = `
@ -454,8 +458,9 @@ window.Page_expenses = (() => {
<select class="form-control" name="kategorie">${katOptions}</select> <select class="form-control" name="kategorie">${katOptions}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Betrag</label> <label class="form-label">Betrag ()</label>
${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })} <input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
value="${r?.betrag || ''}" placeholder="0,00" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Häufigkeit</label> <label class="form-label">Häufigkeit</label>
@ -472,15 +477,15 @@ window.Page_expenses = (() => {
</div> </div>
${dogOptions ? ` ${dogOptions ? `
<div class="form-group"> <div class="form-group">
<label class="form-label">Hund <span class="text-muted">(optional)</span></label> <label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
<select class="form-control" name="dog_id"> <select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions} <option value="">Kein Hund</option>${dogOptions}
</select> </select>
</div>` : ''} </div>` : ''}
<div class="form-group"> <div class="form-group">
<label class="form-label">Bezeichnung <span class="text-muted">(optional)</span></label> <label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="notiz" <input class="form-control" type="text" name="notiz"
value="${UI.escape(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz"> value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div> </div>
</form>`; </form>`;
@ -496,7 +501,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
const payload = { const payload = {
kategorie: fd.kategorie, kategorie: fd.kategorie,
betrag: UI.parseMoney(fd.betrag), betrag: parseFloat(fd.betrag),
haeufigkeit: fd.haeufigkeit, haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum, startdatum: fd.startdatum,
notiz: fd.notiz || null, notiz: fd.notiz || null,
@ -683,13 +688,13 @@ window.Page_expenses = (() => {
const defaultDogId = entry?.dog_id ?? _selectedDogId; const defaultDogId = entry?.dog_id ?? _selectedDogId;
const dogOptions = (_appState.dogs || []).map(d => const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${UI.escape(d.name)}</option>` `<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join(''); ).join('');
// Kategorie-Kacheln statt Dropdown // Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => ` const katKacheln = KATEGORIEN.map(k => `
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}"> <label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} class="hidden"> <input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span> <span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
<span class="exp-kat-tile-label">${k.label}</span> <span class="exp-kat-tile-label">${k.label}</span>
</label>`).join(''); </label>`).join('');
@ -702,10 +707,15 @@ window.Page_expenses = (() => {
<div class="exp-kat-grid">${katKacheln}</div> <div class="exp-kat-grid">${katKacheln}</div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label class="form-label">Betrag</label> <label class="form-label">Betrag</label>
${UI.moneyInput({ name: 'betrag', value: entry?.betrag ?? '', required: true })} <div class="exp-betrag-wrap">
<span class="exp-betrag-prefix"></span>
<input type="number" name="betrag" class="form-control exp-betrag-input"
value="${entry?.betrag || ''}" min="0.01" step="0.01"
placeholder="0,00" required>
</div>
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label> <label class="form-label">Datum</label>
@ -725,7 +735,7 @@ window.Page_expenses = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label> <label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
<input type="text" name="notiz" class="form-control" <input type="text" name="notiz" class="form-control"
value="${UI.escape(entry?.notiz || '')}" value="${_esc(entry?.notiz || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …"> placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div> </div>
@ -800,7 +810,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(ev.target); const fd = UI.formData(ev.target);
const payload = { const payload = {
kategorie: fd.kategorie, kategorie: fd.kategorie,
betrag: UI.parseMoney(fd.betrag), betrag: parseFloat(fd.betrag),
datum: fd.datum, datum: fd.datum,
notiz: fd.notiz || null, notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
@ -852,5 +862,14 @@ window.Page_expenses = (() => {
return Math.round(val) + ' €'; return Math.round(val) + ' €';
} }
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh }; return { init, refresh };
})(); })();

View file

@ -39,7 +39,12 @@ window.Page_forum = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Helpers // Helpers
// ---------------------------------------------------------- // ----------------------------------------------------------
function _fmtDate(iso) { function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _fmtDate(iso) {
if (!iso) return '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
const now = new Date(); const now = new Date();
@ -94,7 +99,7 @@ function _fmtDate(iso) {
<h2 class="forum-header-title">Forum</h2> <h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions"> <div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''} ${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" class="text-muted">${UI.icon('info')} Regeln</button> <button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" style="color:var(--c-text-muted)">${UI.icon('info')} Regeln</button>
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button> <button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div> </div>
</div> </div>
@ -103,7 +108,7 @@ function _fmtDate(iso) {
<div class="forum-category-tabs by-tabs" id="forum-tabs"> <div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => ` ${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}" <button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}"><span class="by-tab-text">${UI.escape(k.label)}</span></button> data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
`).join('')} `).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}" <button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button> data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
@ -212,7 +217,7 @@ function _fmtDate(iso) {
.format(new Date(+year, +month - 1, 1)); .format(new Date(+year, +month - 1, 1));
const top = data.top?.[0]; const top = data.top?.[0];
const winnerLine = top const winnerLine = top
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}` ? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen'; : 'Noch keine Stimmen';
const metaLine = top const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` ? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
@ -222,7 +227,7 @@ function _fmtDate(iso) {
<div class="forum-hdm-tile" id="forum-hdm-tile"> <div class="forum-hdm-tile" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div> <div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body"> <div class="forum-hdm-tile-body">
<div class="forum-hdm-tile-title">Hund des Monats · ${UI.escape(monthName)}</div> <div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
<div class="forum-hdm-tile-winner">${winnerLine}</div> <div class="forum-hdm-tile-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div> <div class="forum-hdm-tile-meta">${metaLine}</div>
</div> </div>
@ -246,16 +251,16 @@ function _fmtDate(iso) {
? data.top.slice(0, 5).map((dog, i) => { ? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i]; const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">` ? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-top-entry"> <div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span> <span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div> <div class="hdm-top-av">${av}</div>
<div class="hdm-top-info"> <div class="hdm-top-info">
<div class="hdm-top-name">${UI.escape(dog.name)}</div> <div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div> </div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div> <div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
@ -275,7 +280,7 @@ function _fmtDate(iso) {
<div class="hdm-kandidaten-search"> <div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control" <input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off" placeholder="Name oder Rasse suchen …" autocomplete="off"
class="text-sm"> style="font-size:var(--text-sm)">
</div> </div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid"> <div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)} ${UI.skeleton(3)}
@ -286,7 +291,7 @@ function _fmtDate(iso) {
<div class="hdm-header"> <div class="hdm-header">
<div class="hdm-trophy">🏆</div> <div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2> <h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${UI.escape(monthName)}</div> <div class="hdm-monat">${_esc(monthName)}</div>
</div> </div>
${voteHint} ${voteHint}
<div class="hdm-section"> <div class="hdm-section">
@ -315,16 +320,16 @@ function _fmtDate(iso) {
grid.innerHTML = list.map(dog => { grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id; const isVoted = data.user_vote === dog.id;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">` ? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}"> <div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div> <div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${UI.escape(dog.name)}</div> <div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer text-xs-muted">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div class="text-xs-muted">${dog.stimmen} ${UI.icon('star')}</div>` : ''} ${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn" <button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}> data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'} ${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
@ -406,8 +411,8 @@ function _fmtDate(iso) {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div> <div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
<p class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p> <p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary mt-4" id="forum-first-btn"> <button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
Ersten Beitrag erstellen Ersten Beitrag erstellen
</button> </button>
</div>`; </div>`;
@ -438,31 +443,31 @@ function _fmtDate(iso) {
function _threadCardHTML(t) { function _threadCardHTML(t) {
const preview = t.text_preview const preview = t.text_preview
? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '') ? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: ''; : '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : ''; const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : ''; const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview) ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>` ? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}" : `<img class="forum-card-thumb" src="${_esc(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''} ${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy" alt="" loading="lazy"
onerror="this.src='${UI.escape(t.foto_preview)}'">` onerror="this.src='${_esc(t.foto_preview)}'">`
: ''; : '';
return ` return `
<div class="forum-thread-card" data-id="${t.id}"> <div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top"> <div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${UI.escape(t.kategorie)}">${UI.escape(t.kategorie)}</span> <span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
${pinBadge}${lockBadge} ${pinBadge}${lockBadge}
</div> </div>
<div class="forum-card-content"> <div class="forum-card-content">
<div class="forum-card-main"> <div class="forum-card-main">
<div class="forum-card-title">${UI.escape(t.titel)}</div> <div class="forum-card-title">${_esc(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''} ${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta"> <div class="forum-card-meta">
<span>${UI.icon('user')} ${UI.escape(t.autor_name || 'Unbekannt')}</span> <span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span> <span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span> <span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span> <span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
@ -488,7 +493,7 @@ function _fmtDate(iso) {
document.getElementById('forum-main').innerHTML = ` document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)"> <div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div> <div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
<p class="text-secondary">Keine Ergebnisse für ${UI.escape(q)}"</p> <p style="color:var(--c-text-secondary)">Keine Ergebnisse für ${_esc(q)}"</p>
</div>`; </div>`;
return; return;
} }
@ -528,19 +533,19 @@ function _fmtDate(iso) {
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}"> <button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'} ${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button> </button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread text-danger">${UI.icon('trash')} Thread</button> <button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : ''; </div>` : '';
const _forumMediaHtml = (u) => { const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf')) if (u.endsWith('.pdf'))
return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card"> return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`; ${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) { if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg'); const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${UI.escape(u)}" poster="${UI.escape(poster)}" controls playsinline return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`; style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
} }
return `<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`; return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
}; };
const fotoGallery = (thread.foto_urls?.length) const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>` ? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
@ -560,7 +565,7 @@ function _fmtDate(iso) {
<div class="forum-reply-actions"> <div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen"> <label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')} ${UI.icon('camera')}
<input type="file" accept="image/*" id="forum-reply-file" class="hidden"> <input type="file" accept="image/*" id="forum-reply-file" style="display:none">
</label> </label>
<div id="forum-reply-previews" class="forum-upload-previews"></div> <div id="forum-reply-previews" class="forum-upload-previews"></div>
</div> </div>
@ -573,20 +578,20 @@ function _fmtDate(iso) {
<div class="forum-thread-detail"> <div class="forum-thread-detail">
${modToolbar} ${modToolbar}
<div class="forum-thread-header-row"> <div class="forum-thread-header-row">
<span class="forum-category-badge forum-category-badge--${UI.escape(thread.kategorie)}">${UI.escape(thread.kategorie)}</span> <span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span> <span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''} ${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''} ${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div> </div>
<div class="forum-thread-body"> <div class="forum-thread-body">
<p style="white-space:pre-wrap;word-break:break-word">${UI.escape(thread.text)}</p> <p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
${fotoGallery} ${fotoGallery}
</div> </div>
<div class="forum-thread-author-row"> <div class="forum-thread-author-row">
<div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div> <div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${UI.escape(thread.autor_name || 'Unbekannt')}</span> <span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''} ${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''}
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center"> <div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}"> <button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
@ -618,7 +623,7 @@ function _fmtDate(iso) {
</div> </div>
` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`; ` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${UI.escape(thread.titel)}`, body, footer }); UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
// Close // Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close); document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
@ -773,7 +778,7 @@ function _fmtDate(iso) {
const isOwn = uid && uid === p.user_id; const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length) const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u => ? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
`<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">` `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>` ).join('')}</div>`
: ''; : '';
@ -783,13 +788,13 @@ function _fmtDate(iso) {
return ` return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}"> <div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header"> <div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(p.autor_name))}</div> <div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${UI.escape(p.autor_name || 'Unbekannt')}</span> <span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''} ${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span> <span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div> </div>
<div class="forum-post-body"> <div class="forum-post-body">
<div class="forum-post-text">${UI.escape(p.text)}</div> <div class="forum-post-text">${_esc(p.text)}</div>
${fotoHtml} ${fotoHtml}
</div> </div>
<div class="forum-post-actions"> <div class="forum-post-actions">
@ -798,7 +803,7 @@ function _fmtDate(iso) {
</button> </button>
${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</button>` : ''} ${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</button>` : ''}
<div style="margin-left:auto;display:flex;gap:4px"> <div style="margin-left:auto;display:flex;gap:4px">
${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${UI.escape(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''} ${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${_esc(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
${canDelete ? `<button class="forum-icon-btn forum-icon-btn--danger forum-post-delete" data-post-id="${p.id}" title="Löschen">${UI.icon('trash')}</button>` : ''} ${canDelete ? `<button class="forum-icon-btn forum-icon-btn--danger forum-post-delete" data-post-id="${p.id}" title="Löschen">${UI.icon('trash')}</button>` : ''}
</div> </div>
</div> </div>
@ -857,7 +862,7 @@ function _fmtDate(iso) {
try { try {
await API.forum.deletePost(postId); await API.forum.deletePost(postId);
if (postEl) { if (postEl) {
postEl.innerHTML = '<em class="text-muted">Beitrag wurde entfernt</em>'; postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
postEl.className = 'forum-post forum-post--deleted'; postEl.className = 'forum-post forum-post--deleted';
} }
const idx = _threads.findIndex(t => t.id === threadId); const idx = _threads.findIndex(t => t.id === threadId);
@ -986,7 +991,7 @@ function _fmtDate(iso) {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showCreateForm() { function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k => const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${UI.escape(k.label)}</option>` `<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
).join(''); ).join('');
const body = ` const body = `
@ -1006,16 +1011,16 @@ function _fmtDate(iso) {
placeholder="Beschreibe dein Thema ausführlich…" required></textarea> placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label> <label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<div id="forum-location-picker"></div> <div id="forum-location-picker"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Fotos / Dateien (max. 5)</label> <label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area"> <div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label> <label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple class="hidden"> <input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple style="display:none">
</div> </div>
<div id="forum-thread-previews" class="forum-upload-previews mt-2"></div> <div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div> </div>
</form> </form>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)"> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
@ -1236,12 +1241,12 @@ function _fmtDate(iso) {
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700; background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35); box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`, border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
_clusterGroup.addLayer( _clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon }) L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`) .bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
); );
}); });
_map.addLayer(_clusterGroup); _map.addLayer(_clusterGroup);
@ -1290,14 +1295,14 @@ function _fmtDate(iso) {
? `<div class="forum-mod-reports"> ? `<div class="forum-mod-reports">
${reports.map(r => ` ${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}"> <div class="forum-mod-report-item" data-id="${r.id}">
<div class="text-sm"> <div style="font-size:var(--text-sm)">
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong> <strong>${_esc(r.target_type)} #${r.target_id}</strong>
${UI.escape(r.grund)} ${_esc(r.grund)}
</div> </div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)} von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div> </div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" class="mt-2"> <button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
${UI.icon('check')} Erledigt ${UI.icon('check')} Erledigt
</button> </button>
</div>`).join('')} </div>`).join('')}
@ -1329,7 +1334,7 @@ function _fmtDate(iso) {
title: 'Antwort bearbeiten', title: 'Antwort bearbeiten',
body: `<form id="${id}"> body: `<form id="${id}">
<div class="form-group"> <div class="form-group">
<textarea class="form-control" name="text" rows="5" required>${UI.escape(currentText)}</textarea> <textarea class="form-control" name="text" rows="5" required>${_esc(currentText)}</textarea>
</div> </div>
</form>`, </form>`,
footer: ` footer: `
@ -1368,14 +1373,14 @@ function _fmtDate(iso) {
body: `<form id="${id}"> body: `<form id="${id}">
<div class="form-group"> <div class="form-group">
<label class="form-label">Titel</label> <label class="form-label">Titel</label>
<input class="form-control" name="titel" value="${UI.escape(thread.titel || '')}" required> <input class="form-control" name="titel" value="${_esc(thread.titel || '')}" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Text</label> <label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5">${UI.escape(thread.text || '')}</textarea> <textarea class="form-control" name="text" rows="5">${_esc(thread.text || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label> <label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<div id="forum-edit-location-picker"></div> <div id="forum-edit-location-picker"></div>
</div> </div>
</form>`, </form>`,
@ -1421,7 +1426,7 @@ function _fmtDate(iso) {
const lb = document.createElement('div'); const lb = document.createElement('div');
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out'; lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom"> lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:44px;height:44px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`; <button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove()); lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb); document.body.appendChild(lb);
} }

View file

@ -51,17 +51,17 @@ window.Page_friends = (() => {
<div> <div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">Dein Freundes-Link</div> color:var(--c-text)">Dein Freundes-Link</div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Teile ihn der andere tippt drauf und findet dich sofort. Teile ihn der andere tippt drauf und findet dich sofort.
</div> </div>
</div> </div>
</div> </div>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
<div style="flex:1;padding:var(--space-2) var(--space-3); <div style="flex:1;padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md); background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary); font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${UI.escape(encodeURIComponent(myName))} banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))}
</div> </div>
<button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren"> <button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
@ -82,7 +82,7 @@ window.Page_friends = (() => {
</svg> </svg>
<input id="fr-search" type="search" autocomplete="off" <input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…" placeholder="Namen eines Hundebesitzers suchen…"
value="${UI.escape(prefill || '')}" value="${_esc(prefill || '')}"
style="width:100%;box-sizing:border-box; style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem; padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg); border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
@ -278,19 +278,17 @@ window.Page_friends = (() => {
const text = item.text || ''; const text = item.text || '';
const page = _ACTIVITY_PAGE[item.type] || ''; const page = _ACTIVITY_PAGE[item.type] || '';
const dogLabel = item.dog_name const dogLabel = item.dog_name
? `<span class="fr-activity-dog">${UI.escape(item.dog_name)}</span>` ? `<span class="fr-activity-dog">${_esc(item.dog_name)}</span>`
: ''; : '';
const avatar = item.dog_foto const avatar = item.dog_foto
? `<img src="${UI.escape(item.dog_foto)}" alt="${UI.escape(item.dog_name || '')}" ? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">` class="fr-activity-avatar">`
: item.avatar_url : item.avatar_url
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}" ? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">` class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial"> : `<div class="fr-activity-avatar fr-activity-avatar--initial">
${UI.escape((item.user_name || '?')[0].toUpperCase())} ${_esc((item.user_name || '?')[0].toUpperCase())}
</div>`; </div>`;
const tag = page ? `button type="button"` : `div`; const tag = page ? `button type="button"` : `div`;
@ -303,17 +301,17 @@ window.Page_friends = (() => {
${avatar} ${avatar}
<div class="fr-activity-icon-badge"> <div class="fr-activity-icon-badge">
<svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true"> <svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true">
<use href="/icons/phosphor.svg#${UI.escape(item.icon)}"></use> <use href="/icons/phosphor.svg#${_esc(item.icon)}"></use>
</svg> </svg>
</div> </div>
</div> </div>
<div class="fr-activity-body"> <div class="fr-activity-body">
<div class="fr-activity-meta"> <div class="fr-activity-meta">
<span class="fr-activity-user">${UI.escape(item.user_name)}</span> <span class="fr-activity-user">${_esc(item.user_name)}</span>
${dogLabel} ${dogLabel}
</div> </div>
${text ? `<div class="fr-activity-text">${UI.escape(text)}</div>` : ''} ${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
<div class="fr-activity-time">${UI.escape(ago)}</div> <div class="fr-activity-time">${_esc(ago)}</div>
</div> </div>
</${page ? 'button' : 'div'}> </${page ? 'button' : 'div'}>
`; `;
@ -353,7 +351,7 @@ window.Page_friends = (() => {
<div style="flex:1;min-width:120px"> <div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text); <div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${UI.escape(r.requester_name)} ${_esc(r.requester_name)}
</div> </div>
${_dogPills(r.dogs, 2)} ${_dogPills(r.dogs, 2)}
</div> </div>
@ -392,12 +390,12 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary); font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0"> font-size:var(--text-sm);flex-shrink:0">
${UI.escape((r.addressee_name || '?')[0].toUpperCase())} ${_esc((r.addressee_name || '?')[0].toUpperCase())}
</div> </div>
<div class="flex-1"> <div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${UI.escape(r.addressee_name)}</div> color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div class="text-xs-muted">Anfrage ausstehend</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
</div> </div>
<button class="btn btn-ghost btn-sm" <button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen"> onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
@ -473,20 +471,20 @@ window.Page_friends = (() => {
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer; <div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
transition:box-shadow 0.15s" transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}" data-friend-id="${f.friend_id}"
data-friend-name="${UI.escape(f.friend_name)}" data-friend-name="${_esc(f.friend_name)}"
data-dogs="${UI.escape(JSON.stringify(dogs))}" data-dogs="${_esc(JSON.stringify(dogs))}"
data-profile="${UI.escape(JSON.stringify(profile))}"> data-profile="${_esc(JSON.stringify(profile))}">
<div style="display:flex;align-items:center;gap:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) --> <!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)} ${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
<!-- Name + Infos + Hunde --> <!-- Name + Infos + Hunde -->
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px; <div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
margin-bottom:var(--space-1)"> margin-bottom:var(--space-1)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)"> <span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${UI.escape(f.friend_name)} ${_esc(f.friend_name)}
</span> </span>
${_erfahrungSpan(f.erfahrung)} ${_erfahrungSpan(f.erfahrung)}
</div> </div>
@ -497,7 +495,7 @@ window.Page_friends = (() => {
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center"> ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)} ${_dogPills(dogs, 3)}
</div>` </div>`
: `<div class="text-xs-muted">Noch kein Hund eingetragen</div>` : `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
} }
</div> </div>
</div> </div>
@ -506,7 +504,7 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm fr-note-btn" <button class="btn btn-ghost btn-sm fr-note-btn"
data-fr-note-id="${f.friend_id}" data-fr-note-id="${f.friend_id}"
data-fr-note-name="${UI.escape(f.friend_name)}" data-fr-note-name="${_esc(f.friend_name)}"
title="Notiz" title="Notiz"
onclick="event.stopPropagation()"> onclick="event.stopPropagation()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
@ -538,14 +536,13 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3); <div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);
padding-top:var(--space-3);border-top:1px solid var(--c-border)"> padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => ` ${withPhotos.slice(0, 4).map(d => `
<div class="text-center"> <div style="text-align:center">
<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}" <img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)"> border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px; <div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${UI.escape(d.name)} ${_esc(d.name)}
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -561,10 +558,9 @@ window.Page_friends = (() => {
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr)); ? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)"> gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => ` ${dogs.map(d => `
<div class="text-center"> <div style="text-align:center">
${d.foto_url ${d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}" ? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:72px;height:72px;border-radius:50%;object-fit:cover; style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">` border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%; : `<div style="width:72px;height:72px;border-radius:50%;
@ -573,9 +569,9 @@ window.Page_friends = (() => {
font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>` font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>`
} }
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${UI.escape(d.name)}</div> color:var(--c-text)">${_esc(d.name)}</div>
${d.rasse ${d.rasse
? `<div class="text-xs-secondary">${UI.escape(d.rasse)}</div>` ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>`
: ''} : ''}
</div> </div>
`).join('')} `).join('')}
@ -589,24 +585,24 @@ window.Page_friends = (() => {
if (profile.wohnort) { if (profile.wohnort) {
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2); parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)"> font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${UI.escape(profile.wohnort)} 📍 ${_esc(profile.wohnort)}
</div>`); </div>`);
} }
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) { if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) {
parts.push(`<div class="text-sm-secondary"> parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${_erfahrungBadge[profile.erfahrung]} ${_erfahrungBadge[profile.erfahrung]}
</div>`); </div>`);
} }
if (profile.bio && profile.profil_sichtbarkeit !== 'private') { if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text); parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.5;padding-top:var(--space-2)"> line-height:1.5;padding-top:var(--space-2)">
${UI.escape(profile.bio)} ${_esc(profile.bio)}
</div>`); </div>`);
} }
if (profile.social_link) { if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all"> parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${UI.escape(profile.social_link)}" target="_blank" rel="noopener noreferrer" <a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
class="text-primary">${UI.escape(profile.social_link)}</a> style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
</div>`); </div>`);
} }
if (!parts.length) return ''; if (!parts.length) return '';
@ -627,7 +623,7 @@ window.Page_friends = (() => {
</div>` : ''; </div>` : '';
UI.modal.open({ UI.modal.open({
title: UI.escape(friendName), title: _esc(friendName),
body: ` body: `
<div> <div>
${badgesHTML} ${badgesHTML}
@ -642,7 +638,7 @@ window.Page_friends = (() => {
Nachricht schreiben Nachricht schreiben
</button> </button>
<button class="btn btn-ghost" id="modal-remove-btn" form="" <button class="btn btn-ghost" id="modal-remove-btn" form=""
class="text-danger"> style="color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
Entfernen Entfernen
</button> </button>
@ -683,11 +679,11 @@ window.Page_friends = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}"> ${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_userAvatar(u.name, null, u.avatar_url)} ${_userAvatar(u.name, null, u.avatar_url)}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px; <div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;
margin-bottom:2px"> margin-bottom:2px">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${UI.escape(u.name)}</span> color:var(--c-text)">${_esc(u.name)}</span>
${u.is_founder ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}</span>` : ''} ${u.is_founder ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}</span>` : ''}
${u.is_partner ? `<span style="font-size:10px;font-weight:700;background:#0ea5e9;color:#fff;padding:1px 5px;border-radius:4px">Partner</span>` : ''} ${u.is_partner ? `<span style="font-size:10px;font-weight:700;background:#0ea5e9;color:#fff;padding:1px 5px;border-radius:4px">Partner</span>` : ''}
${_erfahrungSpan(u.erfahrung)} ${_erfahrungSpan(u.erfahrung)}
@ -697,12 +693,12 @@ window.Page_friends = (() => {
${u.dogs?.length ${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:2px"> margin-top:2px">
${u.dogs.map(d => UI.escape(d.name) + (d.rasse ? ` · ${UI.escape(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')} ${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')}
</div>` </div>`
: ''} : ''}
</div> </div>
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden" <button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
data-user-id="${u.id}" data-user-name="${UI.escape(u.name)}"> data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
</button> </button>
</div> </div>
@ -786,14 +782,12 @@ window.Page_friends = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _userAvatar(name, firstDog, avatarUrl) { function _userAvatar(name, firstDog, avatarUrl) {
if (avatarUrl) { if (avatarUrl) {
return `<img src="${UI.escape(avatarUrl)}" alt="${UI.escape(name)}" return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`; border:2px solid var(--c-primary);flex-shrink:0">`;
} }
if (firstDog?.foto_url) { if (firstDog?.foto_url) {
return `<img src="${UI.escape(firstDog.foto_url)}" alt="${UI.escape(firstDog.name)}" return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`; border:2px solid var(--c-primary);flex-shrink:0">`;
} }
@ -803,7 +797,7 @@ window.Page_friends = (() => {
border:2px solid var(--c-primary); border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)"> font-weight:var(--weight-bold);color:var(--c-primary)">
${UI.escape((name || '?')[0].toUpperCase())} ${_esc((name || '?')[0].toUpperCase())}
</div>`; </div>`;
} }
@ -823,7 +817,7 @@ window.Page_friends = (() => {
function _wohnortLine(wohnort) { function _wohnortLine(wohnort) {
if (!wohnort) return ''; if (!wohnort) return '';
return `<span class="text-xs-muted">📍 ${UI.escape(wohnort)}</span>`; return `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">📍 ${_esc(wohnort)}</span>`;
} }
function _bioLine(bio, sichtbarkeit) { function _bioLine(bio, sichtbarkeit) {
@ -832,7 +826,7 @@ window.Page_friends = (() => {
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-1);line-height:1.4; margin-top:var(--space-1);line-height:1.4;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2; overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;
-webkit-box-orient:vertical">${UI.escape(text)}</div>`; -webkit-box-orient:vertical">${_esc(text)}</div>`;
} }
function _dogPills(dogs, max) { function _dogPills(dogs, max) {
@ -844,7 +838,7 @@ window.Page_friends = (() => {
${visible.map(d => ` ${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full); <span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
background:var(--c-surface-2);color:var(--c-text-secondary)"> background:var(--c-surface-2);color:var(--c-text-secondary)">
🐕 ${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''} 🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''}
</span> </span>
`).join('')} `).join('')}
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''} ${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''}
@ -852,6 +846,11 @@ window.Page_friends = (() => {
`; `;
} }
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _emptyState(icon, title, text, cta = '') { function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state"> return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true"> <svg class="ph-icon empty-state-icon" aria-hidden="true">
@ -881,7 +880,7 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0"> display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div> <div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div> <div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div> </div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button> <button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div> </div>

View file

@ -82,7 +82,7 @@ window.Page_gruender = (() => {
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)"> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)">
Unsere Partner treten gegeneinander an wer bringt die meisten Gründer? Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p> </p>
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.partners.map((p, i) => { ${d.partners.map((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`; const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`;
const barPct = d.partners[0].uses > 0 ? Math.round((p.uses / d.partners[0].uses) * 100) : 0; const barPct = d.partners[0].uses > 0 ? Math.round((p.uses / d.partners[0].uses) * 100) : 0;
@ -91,8 +91,8 @@ window.Page_gruender = (() => {
padding:var(--space-3);border-radius:var(--radius-md); padding:var(--space-3);border-radius:var(--radius-md);
background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}"> background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}">
<div style="font-size:22px;min-width:32px;text-align:center">${medal}</div> <div style="font-size:22px;min-width:32px;text-align:center">${medal}</div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(p.label)}</div> <div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div>
<div style="background:var(--c-surface-3,rgba(0,0,0,.08));border-radius:var(--radius-full); <div style="background:var(--c-surface-3,rgba(0,0,0,.08));border-radius:var(--radius-full);
height:6px;margin-top:var(--space-1);overflow:hidden"> height:6px;margin-top:var(--space-1);overflow:hidden">
<div style="background:#7c3aed;width:${barPct}%;height:100%; <div style="background:#7c3aed;width:${barPct}%;height:100%;
@ -120,7 +120,7 @@ window.Page_gruender = (() => {
background:var(--c-surface-2);display:flex;align-items:center;gap:var(--space-2)"> background:var(--c-surface-2);display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:800;color:#7c3aed;min-width:28px">#${f.founder_number}</span> <span style="font-size:var(--text-xs);font-weight:800;color:#7c3aed;min-width:28px">#${f.founder_number}</span>
<span style="font-size:var(--text-sm);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> <span style="font-size:var(--text-sm);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${UI.escape(f.name)} ${_esc(f.name)}
</span> </span>
</div> </div>
`).join('')} `).join('')}
@ -131,19 +131,23 @@ window.Page_gruender = (() => {
<span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px"> <span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px">
#${d.total + i + 1} #${d.total + i + 1}
</span> </span>
<span class="text-sm-muted">frei</span> <span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
</div> </div>
`).join('')} `).join('')}
</div> </div>
</div>` : ` </div>` : `
<div class="by-card" style="padding:var(--space-6);text-align:center"> <div class="by-card" style="padding:var(--space-6);text-align:center">
<p class="text-sm-muted"> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Gründer sei der Erste! Noch keine Gründer sei der Erste!
</p> </p>
</div>`} </div>`}
`; `;
} }
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

File diff suppressed because it is too large Load diff

View file

@ -103,7 +103,7 @@ window.Page_hilfe = (() => {
</p> </p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0"> <p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search ${_search
? `Zu "${UI.escape(_search)}" wurde nichts gefunden.` ? `Zu "${_esc(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'} : 'Noch keine FAQ-Artikel vorhanden.'}
</p> </p>
</div> </div>
@ -136,7 +136,7 @@ window.Page_hilfe = (() => {
color:var(--c-text-secondary);text-transform:uppercase; color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2); letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)"> margin-bottom:var(--space-1)">
${UI.escape(label)} ${_esc(label)}
</div> </div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)"> <div style="display:flex;flex-direction:column;gap:var(--space-1)">
`; `;
@ -148,12 +148,12 @@ window.Page_hilfe = (() => {
// Highlight Suchtreffer in der Frage // Highlight Suchtreffer in der Frage
const frageHtml = _search const frageHtml = _search
? _highlight(a.frage, _search) ? _highlight(a.frage, _search)
: UI.escape(a.frage); : _esc(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln // Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search const antwortHtml = _search
? _highlight(a.antwort, _search).replace(/\n/g, '<br>') ? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
: UI.escape(a.antwort).replace(/\n/g, '<br>'); : _esc(a.antwort).replace(/\n/g, '<br>');
// Bei aktiver Suche: Antwort gleich aufgeklappt // Bei aktiver Suche: Antwort gleich aufgeklappt
const openByDefault = !!_search; const openByDefault = !!_search;
@ -169,7 +169,7 @@ window.Page_hilfe = (() => {
display:flex;align-items:flex-start;gap:var(--space-2); display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600; font-size:var(--text-sm);font-weight:600;
color:var(--c-text);line-height:1.4"> color:var(--c-text);line-height:1.4">
<span class="flex-1">${frageHtml}</span> <span style="flex:1">${frageHtml}</span>
<svg id="${chevronId}" class="ph-icon" aria-hidden="true" <svg id="${chevronId}" class="ph-icon" aria-hidden="true"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px; style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
color:var(--c-text-muted); color:var(--c-text-muted);
@ -222,12 +222,20 @@ window.Page_hilfe = (() => {
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _highlight(text, term) { function _highlight(text, term) {
if (!term) return text; if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safe})`, 'gi'); const re = new RegExp(`(${safe})`, 'gi');
return UI.escape(text).replace(re, return _esc(text).replace(re,
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>' '<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
); );
} }

View file

@ -26,12 +26,12 @@ window.Page_impressum = (() => {
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2> color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-4)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-4)">
E-Mail: <a href="mailto:hallo@banyaro.app" E-Mail: <a href="mailto:hallo@banyaro.app"
class="text-primary">hallo@banyaro.app</a><br> style="color:var(--c-primary)">hallo@banyaro.app</a><br>
Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden. Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden.
</p> </p>
<form id="contact-form" class="flex-col-gap-3"> <form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div> <div>
<label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label> <label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100" <input id="cf-name" type="text" required maxlength="100"

View file

@ -7,6 +7,7 @@ window.Page_jobs = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
const _esc = s => UI.escape(s ?? '');
const _ph = (name, size = 22) => const _ph = (name, size = 22) =>
`<svg class="ph-icon" aria-hidden="true" style="width:${size}px;height:${size}px;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#${name}"></use></svg>`; `<svg class="ph-icon" aria-hidden="true" style="width:${size}px;height:${size}px;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
@ -43,7 +44,7 @@ window.Page_jobs = (() => {
</div> </div>
<!-- Stellenbeschreibung --> <!-- Stellenbeschreibung -->
<div class="card mb-4"> <div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)"> <div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2> <h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)"> <div style="display:grid;gap:var(--space-3)">
@ -75,7 +76,7 @@ window.Page_jobs = (() => {
</div> </div>
<!-- Wen wir suchen --> <!-- Wen wir suchen -->
<div class="card mb-4"> <div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)"> <div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2> <h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)"> <ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
@ -120,7 +121,7 @@ window.Page_jobs = (() => {
const s = statusMap[app.status] || statusMap.pending; const s = statusMap[app.status] || statusMap.pending;
return ` return `
<div class="card" style="padding:var(--space-5);text-align:center"> <div class="card" style="padding:var(--space-5);text-align:center">
<div class="mb-3"> <div style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:${s.color}"><use href="/icons/phosphor.svg#${s.icon}"></use></svg> <svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:${s.color}"><use href="/icons/phosphor.svg#${s.icon}"></use></svg>
</div> </div>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div> <div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
@ -129,7 +130,7 @@ window.Page_jobs = (() => {
</div> </div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2); ${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm); border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);text-align:left">${UI.escape(app.admin_note)}</div>` : ''} color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}</div>` : ''}
</div>`; </div>`;
} }
@ -146,13 +147,13 @@ window.Page_jobs = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Dein Name *</label> <label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
value="${u ? UI.escape(u.name) : ''}" placeholder="Vorname oder Nickname" required> value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">E-Mail *</label> <label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email" <input class="form-control" type="email" name="email"
value="${u ? UI.escape(u.email || '') : ''}" placeholder="deine@email.de" required> value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
@ -193,7 +194,7 @@ window.Page_jobs = (() => {
<label class="form-label">Anhänge (optional)</label> <label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files" <input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov" multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
class="p-2"> style="padding:var(--space-2)">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)"> <p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB. Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video. PDF, Bild oder Video.
@ -204,7 +205,7 @@ window.Page_jobs = (() => {
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary); padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)"> margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher 💡 <b>Tipp:</b> Wenn du dich vorher
<a href="#" id="jobs-login-link" class="text-primary">anmeldest oder registrierst</a>, <a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
bekommst du sofort den 14-tägigen Luna-Probezugang. bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''} </div>` : ''}

View file

@ -135,11 +135,11 @@ window.Page_knigge = (() => {
const cards = BEGEGNUNGEN.map((b, i) => ` const cards = BEGEGNUNGEN.map((b, i) => `
<div class="knigge-accordion" id="acc-${i}"> <div class="knigge-accordion" id="acc-${i}">
<button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false"> <button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false">
<span>${b.icon} <strong>${UI.escape(b.titel)}</strong></span> <span>${b.icon} <strong>${_esc(b.titel)}</strong></span>
<span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span> <span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span>
</button> </button>
<div class="knigge-accordion-body" id="acc-body-${i}" hidden> <div class="knigge-accordion-body" id="acc-body-${i}" hidden>
<p style="color:var(--c-text);line-height:1.6">${UI.escape(b.tipps)}</p> <p style="color:var(--c-text);line-height:1.6">${_esc(b.tipps)}</p>
</div> </div>
</div> </div>
`).join(''); `).join('');
@ -173,16 +173,16 @@ window.Page_knigge = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _renderVoting() { function _renderVoting() {
const cards = SZENARIEN.map(s => ` const cards = SZENARIEN.map(s => `
<div class="card mb-4" id="sz-${s.id}"> <div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
<p style="font-weight:var(--weight-semibold);margin:0;padding:var(--space-5) var(--space-5) var(--space-3);line-height:1.5"> <p style="font-weight:var(--weight-semibold);margin:0;padding:var(--space-5) var(--space-5) var(--space-3);line-height:1.5">
${UI.escape(s.frage)} ${_esc(s.frage)}
</p> </p>
<div class="knigge-vote-options" id="opts-${s.id}" style="padding:0 var(--space-5) var(--space-5)"> <div class="knigge-vote-options" id="opts-${s.id}" style="padding:0 var(--space-5) var(--space-5)">
${s.antworten.map(a => ` ${s.antworten.map(a => `
<button class="knigge-vote-btn btn btn-secondary" <button class="knigge-vote-btn btn btn-secondary"
data-sz="${s.id}" data-key="${a.key}" data-sz="${s.id}" data-key="${a.key}"
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left"> style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
${UI.escape(a.text)} ${_esc(a.text)}
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -260,12 +260,12 @@ window.Page_knigge = (() => {
? 'var(--c-success, #22c55e)' ? 'var(--c-success, #22c55e)'
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)'); : (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
return ` return `
<div class="mb-3"> <div style="margin-bottom:var(--space-3)">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)"> <div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)">
<span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}"> <span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}">
${isU ? UI.icon('arrow-right') + ' ' : ''}${UI.escape(a.text)}${isR ? ' ' + UI.icon('check') : ''} ${isU ? UI.icon('arrow-right') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
</span> </span>
<span class="text-secondary">${pct}% (${cnt})</span> <span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
</div> </div>
<div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden"> <div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden">
<div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div> <div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div>
@ -282,7 +282,7 @@ window.Page_knigge = (() => {
<div style="margin-bottom:var(--space-4);padding:0 var(--space-5)">${bars}</div> <div style="margin-bottom:var(--space-4);padding:0 var(--space-5)">${bars}</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3) var(--space-5);font-size:var(--text-sm);line-height:1.5"> <div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3) var(--space-5);font-size:var(--text-sm);line-height:1.5">
${badge} ${badge}
<span class="text-secondary">${UI.escape(szenario.erklaerung)}</span> <span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
</div> </div>
`; `;
} }
@ -300,8 +300,8 @@ window.Page_knigge = (() => {
<textarea id="ki-situation-input" class="form-control" <textarea id="ki-situation-input" class="form-control"
rows="3" rows="3"
placeholder="Beschreibe deine Situation…" placeholder="Beschreibe deine Situation…"
class="mb-3"></textarea> style="margin-bottom:var(--space-3)"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" class="w-full"> <button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
Rat holen ${UI.icon('robot')} Rat holen ${UI.icon('robot')}
</button> </button>
<div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div> <div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div>
@ -336,7 +336,7 @@ window.Page_knigge = (() => {
padding:var(--space-4);line-height:1.6;color:var(--c-text)"> padding:var(--space-4);line-height:1.6;color:var(--c-text)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div> color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div>
${UI.escape(data.rat)} ${_esc(data.rat)}
</div> </div>
`; `;
result.style.display = 'block'; result.style.display = 'block';
@ -347,7 +347,7 @@ window.Page_knigge = (() => {
padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)"> padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)">
${is402 ${is402
? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.' ? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.'
: UI.escape(err.message || 'Fehler beim KI-Abruf.')} : _esc(err.message || 'Fehler beim KI-Abruf.')}
</div> </div>
`; `;
result.style.display = 'block'; result.style.display = 'block';
@ -400,7 +400,16 @@ window.Page_knigge = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// HELPER // HELPER
// ---------------------------------------------------------- // ----------------------------------------------------------
// ---------------------------------------------------------- function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh }; return { init, refresh };

View file

@ -14,7 +14,7 @@ window.Page_laeufi = (() => {
_appState = appState; _appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) { if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_container.innerHTML = `<div style="text-align:center;padding:var(--space-10)"> _container.innerHTML = `<div style="text-align:center;padding:var(--space-10)">
<p class="text-secondary">Nur für verifizierte Züchter.</p></div>`; <p style="color:var(--c-text-secondary)">Nur für verifizierte Züchter.</p></div>`;
return; return;
} }
API.breeder.status().then(s => { API.breeder.status().then(s => {
@ -53,7 +53,7 @@ window.Page_laeufi = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${logoHtml}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2> text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
@ -61,7 +61,7 @@ window.Page_laeufi = (() => {
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256"> <svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </svg>
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span> <span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -89,7 +89,7 @@ window.Page_laeufi = (() => {
_renderHundeList(); _renderHundeList();
} catch (err) { } catch (err) {
document.getElementById('laeufi-list').innerHTML = document.getElementById('laeufi-list').innerHTML =
`<p class="text-danger">${UI.escape(err.message || 'Fehler')}</p>`; `<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
} }
} }
@ -129,22 +129,22 @@ window.Page_laeufi = (() => {
<div id="laeufi-toggle-${h.id}" <div id="laeufi-toggle-${h.id}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3); style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
cursor:pointer;user-select:none"> cursor:pointer;user-select:none">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-base);font-weight:700">${UI.escape(h.name)}</span> <span style="font-size:var(--text-base);font-weight:700">${UI.escape(h.name)}</span>
${h.rufname ? `<span class="text-sm-muted">"${UI.escape(h.rufname)}"</span>` : ''} ${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span class="text-xs-muted">${alter}</span>` : ''} ${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''}
</div> </div>
${h.rasse_text || h.farbe ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px"> ${h.rasse_text || h.farbe ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.escape(s)).join(' · ')} ${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.escape(s)).join(' · ')}
</div>` : ''} </div>` : ''}
</div> </div>
<span class="text-muted">${UI.icon('caret-down')}</span> <span style="color:var(--c-text-muted)">${UI.icon('caret-down')}</span>
</div> </div>
<div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)"> <div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}" <div id="laeufi-content-${h.id}"
class="p-4"> style="padding:var(--space-4)">
<p class="text-sm-muted">Lädt</p> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -177,7 +177,7 @@ window.Page_laeufi = (() => {
]); ]);
_renderHundContent(el, hundId, laeufiList, deckList); _renderHundContent(el, hundId, laeufiList, deckList);
} catch (err) { } catch (err) {
el.innerHTML = `<p class="text-danger">${UI.escape(err.message || 'Fehler')}</p>`; el.innerHTML = `<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
} }
} }
@ -270,11 +270,11 @@ window.Page_laeufi = (() => {
return list.map(l => ` return list.map(l => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md); <div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-2);display:flex;gap:var(--space-3);align-items:flex-start"> padding:var(--space-3);margin-bottom:var(--space-2);display:flex;gap:var(--space-3);align-items:flex-start">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-weight:600;font-size:var(--text-sm)">${_fmtDate(l.beginn)}</span> <span style="font-weight:600;font-size:var(--text-sm)">${_fmtDate(l.beginn)}</span>
${l.ende ? `<span class="text-xs-muted">→ ${_fmtDate(l.ende)}</span> ${l.ende ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">→ ${_fmtDate(l.ende)}</span>
<span class="text-xs-muted">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''} <span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</div> </div>
${l.notiz ? `<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin:var(--space-1) 0 0;font-style:italic">${UI.escape(l.notiz)}</p>` : ''} ${l.notiz ? `<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin:var(--space-1) 0 0;font-style:italic">${UI.escape(l.notiz)}</p>` : ''}
</div> </div>
@ -286,7 +286,7 @@ window.Page_laeufi = (() => {
${UI.icon('pencil-simple')} ${UI.icon('pencil-simple')}
</button> </button>
<button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}" <button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}"
title="Löschen" class="text-danger"> title="Löschen" style="color:var(--c-danger)">
${UI.icon('trash')} ${UI.icon('trash')}
</button> </button>
</div> </div>
@ -314,7 +314,7 @@ window.Page_laeufi = (() => {
margin-bottom:var(--space-3);overflow:hidden"> margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header --> <!-- Deck-Header -->
<div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start"> <div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${UI.icon('heart')} Deckung ${_fmtDate(d.deckdatum)}</span> <span style="font-weight:700;font-size:var(--text-sm)">${UI.icon('heart')} Deckung ${_fmtDate(d.deckdatum)}</span>
<span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30; <span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30;
@ -335,7 +335,7 @@ window.Page_laeufi = (() => {
</div> </div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs deck-edit-btn" data-id="${d.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button> <button class="btn btn-ghost btn-xs deck-edit-btn" data-id="${d.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" class="text-danger">${UI.icon('trash')}</button> <button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div> </div>
</div> </div>
<!-- Meilensteine --> <!-- Meilensteine -->
@ -358,7 +358,7 @@ window.Page_laeufi = (() => {
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px"> color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag} ${m.vorbei ? '✓' : m.tag}
</span> </span>
<span class="text-secondary">${_fmtDate(m.datum)}</span> <span style="color:var(--c-text-secondary)">${_fmtDate(m.datum)}</span>
<span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}"> <span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}">
${UI.escape(m.label)} ${UI.escape(m.label)}
</span> </span>
@ -377,8 +377,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen', title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: ` body: `
<form id="laeufi-form" class="flex-col-gap-3"> <form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Beginn *</label> <label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}"> <input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
@ -421,8 +421,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen', title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: ` body: `
<form id="deck-form" class="flex-col-gap-3"> <form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Deckdatum *</label> <label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}"> <input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
@ -435,7 +435,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Rüde</label> <label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden" <input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
@ -451,7 +451,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label> <label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig"> <select class="form-control" name="traechtig">
@ -503,7 +503,7 @@ window.Page_laeufi = (() => {
async function _showProgModal(hundId, laeufi) { async function _showProgModal(hundId, laeufi) {
UI.modal.open({ UI.modal.open({
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`, title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`,
body: `<div id="prog-modal-content"><p class="text-muted">Lädt…</p></div>`, body: `<div id="prog-modal-content"><p style="color:var(--c-text-muted)">Lädt…</p></div>`,
footer: ` footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button> <button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`, <button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
@ -535,7 +535,7 @@ window.Page_laeufi = (() => {
<tbody> <tbody>
${tests.map(t => ` ${tests.map(t => `
<tr style="border-top:1px solid var(--c-border)"> <tr style="border-top:1px solid var(--c-border)">
<td class="p-2">${_fmtDate(t.datum)}</td> <td style="padding:var(--space-2)">${_fmtDate(t.datum)}</td>
<td style="text-align:right;padding:var(--space-2);font-weight:600"> <td style="text-align:right;padding:var(--space-2);font-weight:600">
${t.wert != null ? `${t.wert} ${UI.escape(t.einheit)}` : '—'} ${t.wert != null ? `${t.wert} ${UI.escape(t.einheit)}` : '—'}
${t.wert != null ? `<span style="font-size:10px;margin-left:4px;color:var(--c-text-muted)">${_progEinschaetzung(t.wert, t.einheit)}</span>` : ''} ${t.wert != null ? `<span style="font-size:10px;margin-left:4px;color:var(--c-text-muted)">${_progEinschaetzung(t.wert, t.einheit)}</span>` : ''}
@ -543,7 +543,7 @@ window.Page_laeufi = (() => {
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${t.labor ? UI.escape(t.labor) : '—'}</td> <td style="padding:var(--space-2);color:var(--c-text-secondary)">${t.labor ? UI.escape(t.labor) : '—'}</td>
<td style="padding:var(--space-2);text-align:right"> <td style="padding:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}" <button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}"
class="text-danger">${UI.icon('trash')}</button> style="color:var(--c-danger)">${UI.icon('trash')}</button>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody> </tbody>
@ -572,8 +572,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: 'Progesterontest eintragen', title: 'Progesterontest eintragen',
body: ` body: `
<form id="prog-form" class="flex-col-gap-3"> <form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Datum *</label> <label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}"> <input class="form-control" type="date" name="datum" required value="${today}">
@ -586,7 +586,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Wert</label> <label class="form-label">Wert</label>
<input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5"> <input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5">

View file

@ -19,11 +19,15 @@ window.Page_litters = (() => {
return ` return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div> <div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div>
<h3 style="margin:0 0 var(--space-2)">${UI.escape(title)}</h3> <h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${UI.escape(text)}</p> <p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
</div>`; </div>`;
} }
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) { function _statusBadge(status) {
const map = { const map = {
@ -33,7 +37,7 @@ window.Page_litters = (() => {
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' }, abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
}; };
const s = map[status] || { label: status, cls: 'badge-muted' }; const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge ${s.cls}">${UI.escape(s.label)}</span>`; return `<span class="badge ${s.cls}">${_esc(s.label)}</span>`;
} }
function _fmtDate(iso) { function _fmtDate(iso) {
@ -55,7 +59,7 @@ window.Page_litters = (() => {
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' }, abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
}; };
const s = map[status] || { label: status, cls: 'badge-muted' }; const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge badge-sm ${s.cls}">${UI.escape(s.label)}</span>`; return `<span class="badge badge-sm ${s.cls}">${_esc(s.label)}</span>`;
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -97,7 +101,7 @@ window.Page_litters = (() => {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger'; const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null; const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl const logoHtml = logoUrl
? `<img src="${UI.escape(logoUrl)}" alt="Logo" ? `<img src="${_esc(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover; style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0" border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">` onerror="this.style.display='none'">`
@ -114,15 +118,15 @@ window.Page_litters = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${logoHtml}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2> text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256"> <svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </svg>
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span> <span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -228,7 +232,7 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4); <div style="text-align:center;padding:var(--space-8) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)"> border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<p class="text-muted">Keine Würfe für diesen Filter.</p> <p style="color:var(--c-text-muted)">Keine Würfe für diesen Filter.</p>
</div>`; </div>`;
return; return;
} }
@ -244,8 +248,8 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div> <div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p class="text-secondary">Noch keine Würfe angelegt.</p> <p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary mt-4" id="litters-first-btn"> <button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen ${UI.icon('plus')} Ersten Wurf anlegen
</button> </button>
</div>`; </div>`;
@ -311,7 +315,7 @@ window.Page_litters = (() => {
function _litterCardHTML(l) { function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?'; const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?'; const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => UI.escape(n)).join(' × ') || '—'; const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => _esc(n)).join(' × ') || '—';
// Datum + Countdown // Datum + Countdown
let datumChip = ''; let datumChip = '';
@ -321,10 +325,10 @@ window.Page_litters = (() => {
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`; const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = ''; let countdownHtml = '';
if (days !== null && !l.geburt_datum) { if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span class="text-danger">überfällig</span>` const c = days < 0 ? `<span style="color:var(--c-danger)">überfällig</span>`
: days === 0 ? `<span class="text-success">heute!</span>` : days === 0 ? `<span style="color:var(--c-success)">heute!</span>`
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>` : days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>`
: `<span class="text-muted">${days}d</span>`; : `<span style="color:var(--c-text-muted)">${days}d</span>`;
countdownHtml = ` · ${c}`; countdownHtml = ` · ${c}`;
} }
datumChip = `<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('calendar-dots')} ${label}${countdownHtml}</span>`; datumChip = `<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('calendar-dots')} ${label}${countdownHtml}</span>`;
@ -337,7 +341,7 @@ window.Page_litters = (() => {
const welpenChip = `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar</span>`; const welpenChip = `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar</span>`;
const preisChip = l.preis_spanne const preisChip = l.preis_spanne
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${UI.escape(l.preis_spanne)}</span>` ? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</span>`
: ''; : '';
return ` return `
@ -351,11 +355,11 @@ window.Page_litters = (() => {
<div style="min-width:0"> <div style="min-width:0">
${(l.wurf_rang || l.wurf_name) ? ` ${(l.wurf_rang || l.wurf_name) ? `
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${UI.escape(l.wurf_rang)}-Wurf</span>` : ''} ${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${_esc(l.wurf_rang)}-Wurf</span>` : ''}
${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${UI.escape(l.wurf_name)}</span>` : ''} ${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${_esc(l.wurf_name)}</span>` : ''}
</div>` : ''} </div>` : ''}
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)">
<span class="text-sm-secondary">${elternLabel}</span> <span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${elternLabel}</span>
${_statusBadge(l.status)} ${_statusBadge(l.status)}
${sichtbarChip} ${sichtbarChip}
</div> </div>
@ -386,21 +390,21 @@ window.Page_litters = (() => {
${UI.icon('pencil-simple')} ${UI.icon('pencil-simple')}
</button> </button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}" title="Löschen" <button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}" title="Löschen"
class="text-danger"> style="color:var(--c-danger)">
${UI.icon('trash')} ${UI.icon('trash')}
</button> </button>
</div> </div>
</div> </div>
${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${UI.escape(l.beschreibung)}</p>` : ''} ${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(l.beschreibung)}</p>` : ''}
</div> </div>
<!-- Welpen-Bereich --> <!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)"> <div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="puppies-inner-${l.id}"> <div id="puppies-inner-${l.id}">
<p class="text-sm-muted">Lädt</p> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div> </div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}" <button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
class="mt-3"> style="margin-top:var(--space-3)">
${UI.icon('plus')} Welpen hinzufügen ${UI.icon('plus')} Welpen hinzufügen
</button> </button>
</div> </div>
@ -408,10 +412,10 @@ window.Page_litters = (() => {
<!-- Wartelisten-Bereich --> <!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)"> <div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="waitlist-inner-${l.id}"> <div id="waitlist-inner-${l.id}">
<p class="text-sm-muted">Lädt</p> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div> </div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}" <button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
class="mt-3"> style="margin-top:var(--space-3)">
${UI.icon('plus')} Interessent eintragen ${UI.icon('plus')} Interessent eintragen
</button> </button>
</div> </div>
@ -451,13 +455,13 @@ window.Page_litters = (() => {
const puppies = await API.litters.puppies(litterId); const puppies = await API.litters.puppies(litterId);
_renderPuppies(inner, litterId, puppies); _renderPuppies(inner, litterId, puppies);
} catch (err) { } catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`; inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
} }
} }
function _renderPuppies(container, litterId, puppies) { function _renderPuppies(container, litterId, puppies) {
if (!puppies.length) { if (!puppies.length) {
container.innerHTML = `<p class="text-sm-muted">Noch keine Welpen eingetragen.</p>`; container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Welpen eingetragen.</p>`;
return; return;
} }
@ -465,10 +469,10 @@ window.Page_litters = (() => {
<div class="litters-puppy-row" data-puppy-id="${p.id}"> <div class="litters-puppy-row" data-puppy-id="${p.id}">
<div class="litters-puppy-info"> <div class="litters-puppy-info">
${_genderIcon(p.geschlecht)} ${_genderIcon(p.geschlecht)}
<span class="litters-puppy-name">${p.name ? UI.escape(p.name) : '<em class="text-muted">Unbenannt</em>'}</span> <span class="litters-puppy-name">${p.name ? _esc(p.name) : '<em style="color:var(--c-text-muted)">Unbenannt</em>'}</span>
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(p.farbe)}</span>` : ''} ${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(p.farbe)}</span>` : ''}
${_puppyStatusBadge(p.status)} ${_puppyStatusBadge(p.status)}
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" class="text-xs-secondary"></span> <span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" style="font-size:var(--text-xs);color:var(--c-text-secondary)"></span>
</div> </div>
<div class="litters-puppy-actions"> <div class="litters-puppy-actions">
<button class="btn btn-ghost btn-xs litters-puppy-photo-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}" <button class="btn btn-ghost btn-xs litters-puppy-photo-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
@ -538,16 +542,16 @@ window.Page_litters = (() => {
const puppyLabel = puppy.name || 'Welpe'; const puppyLabel = puppy.name || 'Welpe';
const body = ` const body = `
<div id="weight-history" class="mb-3"> <div id="weight-history" style="margin-bottom:var(--space-3)">
<p class="text-sm-muted">Lädt</p> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div> </div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)"> <hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="weight-form" style="display:flex;gap:var(--space-2);align-items:flex-end"> <form id="weight-form" style="display:flex;gap:var(--space-2);align-items:flex-end">
<div class="flex-1"> <div style="flex:1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Gewicht (g)</label> <label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Gewicht (g)</label>
<input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420"> <input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
</div> </div>
<div class="flex-1"> <div style="flex:1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Datum</label> <label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Datum</label>
<input class="form-control" name="gemessen_am" type="date" required value="${today}"> <input class="form-control" name="gemessen_am" type="date" required value="${today}">
</div> </div>
@ -561,7 +565,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('scales')} Gewichtsverlauf — ${UI.escape(puppyLabel)}`, title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
body, body,
footer, footer,
}); });
@ -596,7 +600,7 @@ window.Page_litters = (() => {
try { try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`); const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
if (!weights || !weights.length) { if (!weights || !weights.length) {
el.innerHTML = `<p class="text-sm-muted">Noch keine Messungen eingetragen.</p>`; el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Messungen eingetragen.</p>`;
return; return;
} }
@ -626,22 +630,22 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<!-- Stats-Zeile --> <!-- Stats-Zeile -->
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<div class="text-center"> <div style="text-align:center">
<div class="text-xs-muted">Aktuell</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Aktuell</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div> <div style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div>
</div> </div>
<div class="text-center"> <div style="text-align:center">
<div class="text-xs-muted">Zunahme</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Zunahme</div>
<div style="font-size:var(--text-base);font-weight:700;color:${gain >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}"> <div style="font-size:var(--text-base);font-weight:700;color:${gain >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}">
${gain >= 0 ? '+' : ''}${gain} g ${gain >= 0 ? '+' : ''}${gain} g
</div> </div>
</div> </div>
<div class="text-center"> <div style="text-align:center">
<div class="text-xs-muted">Ø tägl.</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Ø tägl.</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div> <div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div>
</div> </div>
<div class="text-center"> <div style="text-align:center">
<div class="text-xs-muted">Messungen</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">Messungen</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div> <div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div>
</div> </div>
</div> </div>
@ -691,7 +695,7 @@ window.Page_litters = (() => {
</tbody> </tbody>
</table>`; </table>`;
} catch (err) { } catch (err) {
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`; el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
} }
} }
@ -733,7 +737,7 @@ window.Page_litters = (() => {
btn.innerHTML = `${UI.icon('list-bullets')} Warteliste${active ? ` <span style="background:var(--c-primary);color:white;border-radius:999px;padding:0 6px;font-size:10px;font-weight:700">${active}</span>` : ''}`; btn.innerHTML = `${UI.icon('list-bullets')} Warteliste${active ? ` <span style="background:var(--c-primary);color:white;border-radius:999px;padding:0 6px;font-size:10px;font-weight:700">${active}</span>` : ''}`;
} }
} catch (err) { } catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler.')}</p>`; inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler.')}</p>`;
} }
} }
@ -760,34 +764,34 @@ window.Page_litters = (() => {
<div style="text-align:center;padding:var(--space-6) var(--space-4);border:1px dashed var(--c-border);border-radius:var(--radius-md)"> <div style="text-align:center;padding:var(--space-6) var(--space-4);border:1px dashed var(--c-border);border-radius:var(--radius-md)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('users')}</div> <div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('users')}</div>
<p style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-1)">Noch keine Interessenten</p> <p style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-1)">Noch keine Interessenten</p>
<p class="text-xs-muted">Trage Anfragen ein mit Wunsch-Geschlecht, Kontaktdaten und Status.</p> <p style="font-size:var(--text-xs);color:var(--c-text-muted)">Trage Anfragen ein mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
</div>`; </div>`;
return; return;
} }
container.innerHTML = header + ` container.innerHTML = header + `
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${entries.map((e, i) => ` ${entries.map((e, i) => `
<div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}"> <div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}">
<div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div> <div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:600;font-size:var(--text-sm)">${UI.escape(e.name)}</span> <span style="font-weight:600;font-size:var(--text-sm)">${_esc(e.name)}</span>
${_wlStatusBadge(e.status)} ${_wlStatusBadge(e.status)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span class="text-xs-secondary">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''} ${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span class="text-xs-secondary">${UI.escape(e.wunsch_farbe)}</span>` : ''} ${e.wunsch_farbe ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(e.wunsch_farbe)}</span>` : ''}
</div> </div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)"> <div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${e.email ? `<span>${UI.icon('envelope')} ${UI.escape(e.email)}</span>` : ''} ${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${UI.escape(e.telefon)}</span>` : ''} ${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
<span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span> <span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div> </div>
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${UI.escape(e.nachricht)}"</div>` : ''} ${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${_esc(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${UI.escape(e.notiz)}</div>` : ''} ${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${_esc(e.notiz)}</div>` : ''}
</div> </div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button> <button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" class="text-danger">${UI.icon('trash')}</button> <button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div> </div>
</div>`).join('')} </div>`).join('')}
</div>`; </div>`;
@ -816,22 +820,22 @@ window.Page_litters = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen', title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: ` body: `
<form id="wl-form" class="flex-col-gap-3"> <form id="wl-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Name *</label> <label class="form-label">Name *</label>
<input class="form-control" name="name" required value="${UI.escape(v.name || '')}"> <input class="form-control" name="name" required value="${_esc(v.name || '')}">
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">E-Mail</label> <label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email" value="${UI.escape(v.email || '')}"> <input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Telefon</label> <label class="form-label">Telefon</label>
<input class="form-control" name="telefon" value="${UI.escape(v.telefon || '')}"> <input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Wunsch Geschlecht</label> <label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht"> <select class="form-control" name="wunsch_geschlecht">
@ -842,14 +846,14 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Wunsch Farbe</label> <label class="form-label">Wunsch Farbe</label>
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${UI.escape(v.wunsch_farbe || '')}"> <input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${_esc(v.wunsch_farbe || '')}">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Nachricht des Interessenten</label> <label class="form-label">Nachricht des Interessenten</label>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${UI.escape(v.nachricht || '')}</textarea> <textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${_esc(v.nachricht || '')}</textarea>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Status</label> <label class="form-label">Status</label>
<select class="form-control" name="status"> <select class="form-control" name="status">
@ -863,7 +867,7 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Interne Notiz</label> <label class="form-label">Interne Notiz</label>
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${UI.escape(v.notiz || '')}"> <input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${_esc(v.notiz || '')}">
</div> </div>
</form>`, </form>`,
footer: ` footer: `
@ -915,15 +919,15 @@ window.Page_litters = (() => {
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => { const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => { const opts = list.map(h => {
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : ''); const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : '');
return `<option value="${h.id}" data-name="${UI.escape(h.name)}" ${currentId == h.id ? 'selected' : ''}>${UI.escape(label)}</option>`; return `<option value="${h.id}" data-name="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
}).join(''); }).join('');
return ` return `
<select class="form-control" name="${idName}" id="${idName}-sel" class="mb-2"> <select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
<option value=""> ${placeholder} </option> <option value=""> ${placeholder} </option>
${opts} ${opts}
</select> </select>
<input class="form-control" type="text" name="${name}" id="${name}-txt" <input class="form-control" type="text" name="${name}" id="${name}-txt"
value="${UI.escape(currentName || '')}" placeholder="oder Namen frei eingeben">`; value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
}; };
const rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l => const rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l =>
@ -945,11 +949,11 @@ window.Page_litters = (() => {
<label class="form-label">Wurf-Name <span style="font-weight:normal;color:var(--c-text-muted)">(optional)</span></label> <label class="form-label">Wurf-Name <span style="font-weight:normal;color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="wurf_name" <input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …" placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${UI.escape(v.wurf_name || '')}"> value="${_esc(v.wurf_name || '')}">
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Vater</label> <label class="form-label">Vater</label>
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')} ${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
@ -964,18 +968,18 @@ window.Page_litters = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Erwarteter Geburtstermin <span style="font-weight:normal;color:var(--c-text-muted)">(geplant)</span></label> <label class="form-label">Erwarteter Geburtstermin <span style="font-weight:normal;color:var(--c-text-muted)">(geplant)</span></label>
<input class="form-control" type="date" name="erwartetes_datum" <input class="form-control" type="date" name="erwartetes_datum"
value="${UI.escape(v.erwartetes_datum || '')}"> value="${_esc(v.erwartetes_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Für geplante Würfe / laufende Trächtigkeit</p> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Für geplante Würfe / laufende Trächtigkeit</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtsdatum <span style="font-weight:normal;color:var(--c-text-muted)">(tatsächlich)</span></label> <label class="form-label">Geburtsdatum <span style="font-weight:normal;color:var(--c-text-muted)">(tatsächlich)</span></label>
<input class="form-control" type="date" name="geburt_datum" <input class="form-control" type="date" name="geburt_datum"
value="${UI.escape(v.geburt_datum || '')}"> value="${_esc(v.geburt_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Wenn die Welpen bereits geboren sind</p> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Wenn die Welpen bereits geboren sind</p>
</div> </div>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Welpen gesamt</label> <label class="form-label">Welpen gesamt</label>
<input class="form-control" type="number" name="welpen_gesamt" min="0" <input class="form-control" type="number" name="welpen_gesamt" min="0"
@ -1001,19 +1005,19 @@ window.Page_litters = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Preisspanne</label> <label class="form-label">Preisspanne</label>
<input class="form-control" type="text" name="preis_spanne" <input class="form-control" type="text" name="preis_spanne"
value="${UI.escape(v.preis_spanne || '')}" placeholder="z. B. 1.500 2.000 €"> value="${_esc(v.preis_spanne || '')}" placeholder="z. B. 1.500 2.000 €">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label> <label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="3" <textarea class="form-control" name="beschreibung" rows="3"
placeholder="Elternlinie, Besonderheiten, Charakter…">${UI.escape(v.beschreibung || '')}</textarea> placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Gesundheitstests <span class="text-secondary">(optional)</span></label> <label class="form-label">Gesundheitstests <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="gesundheitstests" rows="2" <textarea class="form-control" name="gesundheitstests" rows="2"
placeholder="HD, ED, Gentest, Augenkontrolle…">${UI.escape(v.gesundheitstests || '')}</textarea> placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -1024,9 +1028,9 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Sichtbar bis <span class="text-secondary">(optional)</span></label> <label class="form-label">Sichtbar bis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="date" name="sichtbar_bis" <input class="form-control" type="date" name="sichtbar_bis"
value="${UI.escape(v.sichtbar_bis || '')}"> value="${_esc(v.sichtbar_bis || '')}">
</div> </div>
</form> </form>
@ -1130,11 +1134,11 @@ window.Page_litters = (() => {
const body = ` const body = `
<form id="puppy-form" autocomplete="off"> <form id="puppy-form" autocomplete="off">
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Name <span class="text-secondary">(optional)</span></label> <label class="form-label">Name <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
value="${UI.escape(v.name || '')}" placeholder="z. B. Max"> value="${_esc(v.name || '')}" placeholder="z. B. Max">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geschlecht</label> <label class="form-label">Geschlecht</label>
@ -1149,7 +1153,7 @@ window.Page_litters = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Farbe / Fellzeichnung</label> <label class="form-label">Farbe / Fellzeichnung</label>
<input class="form-control" type="text" name="farbe" <input class="form-control" type="text" name="farbe"
value="${UI.escape(v.farbe || '')}" placeholder="z. B. schwarz-braun"> value="${_esc(v.farbe || '')}" placeholder="z. B. schwarz-braun">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -1161,11 +1165,11 @@ window.Page_litters = (() => {
</select> </select>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Chip-Nr.</label> <label class="form-label">Chip-Nr.</label>
<input class="form-control" type="text" name="chip_nr" <input class="form-control" type="text" name="chip_nr"
value="${UI.escape(v.chip_nr || '')}" placeholder="15-stellig"> value="${_esc(v.chip_nr || '')}" placeholder="15-stellig">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtsgewicht (g)</label> <label class="form-label">Geburtsgewicht (g)</label>
@ -1182,9 +1186,9 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Notiz <span class="text-secondary">(intern)</span></label> <label class="form-label">Notiz <span style="color:var(--c-text-secondary)">(intern)</span></label>
<textarea class="form-control" name="notiz" rows="2" <textarea class="form-control" name="notiz" rows="2"
placeholder="Interne Notizen…">${UI.escape(v.notiz || '')}</textarea> placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
</div> </div>
</form> </form>
@ -1245,22 +1249,22 @@ window.Page_litters = (() => {
const body = ` const body = `
<form id="contract-form" autocomplete="off"> <form id="contract-form" autocomplete="off">
<div class="form-group"> <div class="form-group">
<label class="form-label">Name des Käufers <span class="text-danger">*</span></label> <label class="form-label">Name des Käufers <span style="color:var(--c-danger)">*</span></label>
<input class="form-control" type="text" name="kaeufer_name" required <input class="form-control" type="text" name="kaeufer_name" required
placeholder="Vor- und Nachname"> placeholder="Vor- und Nachname">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Adresse des Käufers <span class="text-danger">*</span></label> <label class="form-label">Adresse des Käufers <span style="color:var(--c-danger)">*</span></label>
<textarea class="form-control" name="kaeufer_adresse" rows="2" required <textarea class="form-control" name="kaeufer_adresse" rows="2" required
placeholder="Straße, PLZ, Ort"></textarea> placeholder="Straße, PLZ, Ort"></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">E-Mail des Käufers <span class="text-secondary">(optional)</span></label> <label class="form-label">E-Mail des Käufers <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="email" name="kaeufer_email" <input class="form-control" type="email" name="kaeufer_email"
placeholder="kaeufer@beispiel.de"> placeholder="kaeufer@beispiel.de">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Kaufpreis <span class="text-secondary">(optional)</span></label> <label class="form-label">Kaufpreis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="preis" <input class="form-control" type="text" name="preis"
placeholder="z. B. 1.500 €"> placeholder="z. B. 1.500 €">
</div> </div>
@ -1275,7 +1279,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('file-text')} Kaufvertrag — ${UI.escape(puppyLabel)}`, title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
body, body,
footer, footer,
}); });
@ -1313,11 +1317,11 @@ window.Page_litters = (() => {
const visOrder = ['public', 'inquiry', 'private']; const visOrder = ['public', 'inquiry', 'private'];
const body = ` const body = `
<div id="${galleryId}" class="mb-4"> <div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p class="text-sm-muted">Lädt</p> <p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div> </div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)"> <hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="${uploadFormId}" class="flex-col-gap-2"> <form id="${uploadFormId}" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)"> <label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
${UI.icon('upload-simple')} Foto hochladen ${UI.icon('upload-simple')} Foto hochladen
</label> </label>
@ -1332,7 +1336,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('images')} Fotos — ${UI.escape(label)}`, title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
body, body,
footer, footer,
}); });
@ -1344,7 +1348,7 @@ window.Page_litters = (() => {
try { try {
const photos = await API.breederPhotos.list(entityType, entityId); const photos = await API.breederPhotos.list(entityType, entityId);
if (!photos.length) { if (!photos.length) {
el.innerHTML = `<p class="text-sm-muted">Noch keine Fotos vorhanden.</p>`; el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos vorhanden.</p>`;
return; return;
} }
el.innerHTML = ` el.innerHTML = `
@ -1354,21 +1358,21 @@ window.Page_litters = (() => {
const vis = visLabels[ph.visibility] || visLabels.private; const vis = visLabels[ph.visibility] || visLabels.private;
return ` return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1"> <div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1">
<a href="${UI.escape(ph.url || '')}" target="_blank" rel="noopener noreferrer"> <a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}" <img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
loading="lazy" loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.src='/static/img/placeholder.webp'"> onerror="this.src='/static/img/placeholder.webp'">
</a> </a>
<button class="photos-vis-btn" <button class="photos-vis-btn"
data-photo-id="${ph.id}" data-photo-id="${ph.id}"
data-vis="${UI.escape(ph.visibility)}" data-vis="${_esc(ph.visibility)}"
title="Sichtbarkeit ändern" title="Sichtbarkeit ändern"
style="position:absolute;bottom:0;left:0;right:0; style="position:absolute;bottom:0;left:0;right:0;
background:${vis.color};color:#fff; background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:10px;padding:2px 4px; border:none;cursor:pointer;font-size:10px;padding:2px 4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(vis.text)} ${_esc(vis.text)}
</button> </button>
<button class="photos-del-btn" <button class="photos-del-btn"
data-photo-id="${ph.id}" data-photo-id="${ph.id}"
@ -1414,7 +1418,7 @@ window.Page_litters = (() => {
} catch (err) { } catch (err) {
const el = document.getElementById(galleryId); const el = document.getElementById(galleryId);
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`; if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
} }
} }
@ -1460,13 +1464,13 @@ window.Page_litters = (() => {
const issueHTML = (welfare.issues || []).map(i => ` const issueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)"> <div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)">
<span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span> <span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span>
<span class="text-sm">${UI.escape(i.text)}</span> <span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
</div>`).join(''); </div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => ` const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0"> <div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span> <span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
<span class="text-sm-secondary">${UI.escape(p)}</span> <span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
</div>`).join(''); </div>`).join('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical'; const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
@ -1496,7 +1500,7 @@ window.Page_litters = (() => {
Trotzdem fortfahren Trotzdem fortfahren
</button> </button>
</div>` : ` </div>` : `
<button class="btn btn-primary" data-modal-close class="w-full"> <button class="btn btn-primary" data-modal-close style="width:100%">
${UI.icon('check')} Verstanden ${UI.icon('check')} Verstanden
</button>`, </button>`,
}); });
@ -1536,7 +1540,7 @@ window.Page_litters = (() => {
} catch (err) { } catch (err) {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`, title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<p class="text-danger">${UI.escape(err.message || 'Fehler beim Generieren.')}</p>`, body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`, footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
}); });
return; return;
@ -1544,7 +1548,7 @@ window.Page_litters = (() => {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`, title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${UI.escape(text)}</div>`, body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: ` footer: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy"> <button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren ${UI.icon('clipboard-text')} Kopieren

View file

@ -130,24 +130,54 @@ window.Page_lost = (() => {
document.getElementById('lost-btn-report') document.getElementById('lost-btn-report')
?.addEventListener('click', _showReportForm); ?.addEventListener('click', _showReportForm);
await _initMap(); await _loadLeaflet();
_initMap();
setTimeout(() => _map?.invalidateSize(), 100); setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad(); await _locateAndLoad();
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// KARTE INITIALISIEREN (lädt Leaflet via UI.map.create) // LEAFLET DYNAMISCH LADEN
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _initMap() { async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
await new Promise(resolve => {
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = resolve;
link.onerror = resolve;
document.head.appendChild(link);
});
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
_injectStyles(); _injectStyles();
const mapEl = document.getElementById('lost-map'); const mapEl = document.getElementById('lost-map');
if (!mapEl || _map) return; if (!mapEl || !window.L || _map) return;
_map = await UI.map.create('lost-map', { _map = L.map('lost-map', { zoomControl: true, attributionControl: false })
center: [51.1657, 10.4515], zoom: 6, .setView([51.1657, 10.4515], 6);
zoomControl: true, attributionControl: false,
}); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
_leafletLoaded = true; maxZoom: 19,
}).addTo(_map);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -273,21 +303,26 @@ window.Page_lost = (() => {
_reports.forEach(r => { _reports.forEach(r => {
const dotColor = r._isPending ? '#d97706' : '#e74c3c'; const dotColor = r._isPending ? '#d97706' : '#e74c3c';
const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r'; const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r';
const html = `<div style="background:${dotColor};color:#fff;border-radius:50%; const icon = L.divIcon({
className : '',
html : `<div style="background:${dotColor};color:#fff;border-radius:50%;
width:34px;height:34px; width:34px;height:34px;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-size:17px;border:2px solid #fff; font-size:17px;border:2px solid #fff;
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`; animation:${anim} 1.8s ease-in-out infinite">🐕</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m !== undefined const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) ? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: ''; : '';
const marker = UI.map.svgMarker(r.lat, r.lon, html, { size: 34, anchorY: 17 }) const marker = L.marker([r.lat, r.lon], { icon })
.addTo(_map) .addTo(_map)
.bindPopup(` .bindPopup(`
<b>🔍 ${UI.escape(r.name)}</b><br> <b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? UI.escape(r.rasse) + '<br>' : ''} ${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''} ${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''} ${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
<small>📅 ${_fmtDate(r.created_at)}</small> <small>📅 ${_fmtDate(r.created_at)}</small>
@ -378,14 +413,14 @@ window.Page_lost = (() => {
border-radius:var(--radius-md);flex-shrink:0; border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-size:2rem">🐕</div>`} font-size:2rem">🐕</div>`}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2); <div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap"> margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)"> <span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${UI.escape(r.name)} ${_escape(r.name)}
</span> </span>
${r.rasse ${r.rasse
? `<span class="badge">${UI.escape(r.rasse)}</span>` ? `<span class="badge">${_escape(r.rasse)}</span>`
: ''} : ''}
${isOwn ${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>' ? '<span class="badge badge-warning">Meine Meldung</span>'
@ -399,11 +434,11 @@ window.Page_lost = (() => {
</div> </div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm); <p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)"> color:var(--c-text)">
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''} ${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p> </p>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)} Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + UI.escape(r.melder_name.split(' ')[0]) : ''} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div> </div>
${r._isPending ${r._isPending
? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> ? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
@ -415,10 +450,10 @@ window.Page_lost = (() => {
🗑 Verwerfen 🗑 Verwerfen
</button> </button>
</div>` </div>`
: (_appState.user ? `<div class="mt-2"> : (_appState.user ? `<div style="margin-top:var(--space-2)">
<button class="btn btn-ghost btn-xs lost-note-btn" <button class="btn btn-ghost btn-xs lost-note-btn"
data-lost-note-id="${r.id}" data-lost-note-id="${r.id}"
data-lost-note-name="${UI.escape(r.name)}" data-lost-note-name="${_escape(r.name)}"
title="Notiz" onclick="event.stopPropagation()"> title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button> </button>
@ -447,19 +482,19 @@ window.Page_lost = (() => {
: ''} : ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-danger">🐕 ${UI.escape(r.name)}</span> <span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${UI.escape(r.rasse)}</span>` : ''} ${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
</div> </div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)"> <p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${UI.escape(r.beschreibung)} ${_escape(r.beschreibung)}
</p> </p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary); <div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8"> margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div> <div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div> <div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name.split(' ')[0])}</div>` : ''} ${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
</div> </div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
@ -473,7 +508,7 @@ window.Page_lost = (() => {
</div> </div>
`; `;
UI.modal.open({ title: `🔍 ${UI.escape(r.name)} wird vermisst`, body }); UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
document.getElementById('detail-lost-map')?.addEventListener('click', () => { document.getElementById('detail-lost-map')?.addEventListener('click', () => {
UI.modal.close(); UI.modal.close();
@ -511,10 +546,10 @@ window.Page_lost = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showFoundDialog(r) { function _showFoundDialog(r) {
UI.modal.open({ UI.modal.open({
title: `🎉 ${UI.escape(r.name)} gefunden?`, title: `🎉 ${_escape(r.name)} gefunden?`,
body: ` 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)">
Wurde ${UI.escape(r.name)} wiedergefunden? Die Meldung wird als Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
abgeschlossen markiert und aus der Liste entfernt. abgeschlossen markiert und aus der Liste entfernt.
</p> </p>
`, `,
@ -555,7 +590,7 @@ window.Page_lost = (() => {
const dogs = _appState.dogs || []; const dogs = _appState.dogs || [];
const dogOpts = dogs.length > 0 const dogOpts = dogs.length > 0
? `<option value="">— kein registrierter Hund —</option>` + ? `<option value="">— kein registrierter Hund —</option>` +
dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}${d.rasse ? ' (' + UI.escape(d.rasse) + ')' : ''}</option>`).join('') dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
: ''; : '';
const body = ` const body = `
@ -565,7 +600,7 @@ window.Page_lost = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Registrierter Hund Registrierter Hund
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<select class="form-control" name="dog_id" id="lf-dog-select"> <select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts} ${dogOpts}
@ -581,7 +616,7 @@ window.Page_lost = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Rasse Rasse
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<input class="form-control" type="text" name="rasse" <input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador"> placeholder="z. B. Labrador">
@ -608,7 +643,7 @@ window.Page_lost = (() => {
</div> </div>
<input type="hidden" name="lat" id="lf-lat"> <input type="hidden" name="lat" id="lf-lat">
<input type="hidden" name="lon" id="lf-lon"> <input type="hidden" name="lon" id="lf-lon">
<small id="lf-gps-hint" class="text-secondary"> <small id="lf-gps-hint" style="color:var(--c-text-secondary)">
${_userPos ${_userPos
? '✅ Aktueller Standort vorausgefüllt' ? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'} : 'GPS-Button drücken um Standort zu ermitteln'}
@ -618,7 +653,7 @@ window.Page_lost = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Foto Foto
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<input class="form-control" type="file" name="photo" <input class="form-control" type="file" name="photo"
accept="image/*" capture="environment"> accept="image/*" capture="environment">
@ -790,7 +825,17 @@ window.Page_lost = (() => {
day: '2-digit', month: '2-digit', year: 'numeric' day: '2-digit', month: '2-digit', year: 'numeric'
}); });
} }
function _emptyState(icon, title, text, cta = '') {
function _escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state"> return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true"> <svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>
@ -819,7 +864,7 @@ function _emptyState(icon, title, text, cta = '') {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0"> display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div> <div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div> <div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_escape(parentLabel)}</div>
</div> </div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button> <button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div> </div>

View file

@ -221,7 +221,7 @@ window.Page_map = (() => {
<div class="map-statusbar" id="map-statusbar"> <div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span> <span id="map-zoom-info"></span>
<span id="map-osm-status" class="hidden"></span> <span id="map-osm-status" style="display:none"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span> <span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span> <span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div> </div>
@ -1344,15 +1344,15 @@ window.Page_map = (() => {
}); });
const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 }) const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(UI.escape(b.zwingername), { direction: 'top', offset: [0, -16] }); .bindTooltip(_esc(b.zwingername), { direction: 'top', offset: [0, -16] });
marker.on('click', () => { marker.on('click', () => {
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${UI.escape(b.rasse_text)}</div>` : ''; const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${_esc(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${UI.escape(b.stadt)}</div>` : ''; const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
marker.bindPopup(` marker.bindPopup(`
<div style="min-width:170px;max-width:240px"> <div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${UI.escape(b.zwingername)}</div> <div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(b.zwingername)}</div>
${rasseText}${stadtText} ${rasseText}${stadtText}
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button> <button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
</div> </div>
@ -1780,7 +1780,7 @@ window.Page_map = (() => {
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer; border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none"> font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''} <input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
class="rec-dog-cb hidden"> style="display:none" class="rec-dog-cb">
${av}<span>${UI.escape(d.name)}</span> ${av}<span>${UI.escape(d.name)}</span>
</label>`; </label>`;
}).join('')} }).join('')}
@ -1798,7 +1798,7 @@ window.Page_map = (() => {
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required> placeholder="Wird automatisch ermittelt…" required>
</div> </div>
<div class="grid-2"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Schwierigkeit</label> <label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit"> <select class="form-control" name="schwierigkeit">
@ -1842,7 +1842,7 @@ window.Page_map = (() => {
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label> <label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2" <textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea> placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div> </div>

View file

@ -161,20 +161,20 @@ window.Page_moderation = (() => {
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:var(--space-4)"> gap:var(--space-4)">
${fotos.map(f => ` ${fotos.map(f => `
<div class="card p-4" data-id="${f.id}"> <div class="card" style="padding:var(--space-4)" data-id="${f.id}">
<a href="#wiki?rasse=${UI.escape(f.rasse_slug)}" style="display:block;text-decoration:none"> <a href="#wiki?rasse=${_esc(f.rasse_slug)}" style="display:block;text-decoration:none">
<img src="${UI.escape(f.foto_url)}" alt="" <img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover; style="width:100%;height:140px;object-fit:cover;
border-radius:var(--radius-md);margin-bottom:var(--space-3)"> border-radius:var(--radius-md);margin-bottom:var(--space-3)">
</a> </a>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)"> <div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">
${UI.escape(f.rasse_name || f.rasse_slug)} ${_esc(f.rasse_name || f.rasse_slug)}
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted); <div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2)"> margin-bottom:var(--space-2)">
von ${UI.escape(f.user_name)} von ${_esc(f.user_name)}
</div> </div>
<div class="mb-3"> <div style="margin-bottom:var(--space-3)">
${f.rights_confirmed ${f.rights_confirmed
? `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px; ? `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;
background:#dcfce7;color:#166534"> Bildrechte bestätigt</span>` background:#dcfce7;color:#166534"> Bildrechte bestätigt</span>`
@ -183,17 +183,17 @@ window.Page_moderation = (() => {
</div> </div>
${f.aktuell_foto ? ` ${f.aktuell_foto ? `
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:4px">Aktuell:</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:4px">Aktuell:</div>
<img src="${UI.escape(f.aktuell_foto)}" alt="Aktuell" <img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:70px;object-fit:cover; style="width:100%;height:70px;object-fit:cover;
border-radius:var(--radius-sm);opacity:.5; border-radius:var(--radius-sm);opacity:.5;
margin-bottom:var(--space-3)"> margin-bottom:var(--space-3)">
` : `<div style="font-size:var(--text-xs);color:var(--c-warning); ` : `<div style="font-size:var(--text-xs);color:var(--c-warning);
margin-bottom:var(--space-3)">Noch kein Foto vorhanden</div>`} margin-bottom:var(--space-3)">Noch kein Foto vorhanden</div>`}
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm btn-primary mod-foto-approve" <button class="btn btn-sm btn-primary mod-foto-approve"
data-id="${f.id}" class="flex-1"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button> data-id="${f.id}" style="flex:1"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
<button class="btn btn-sm btn-ghost mod-foto-reject" <button class="btn btn-sm btn-ghost mod-foto-reject"
data-id="${f.id}" class="text-danger"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> Ablehnen</button> data-id="${f.id}" style="color:var(--c-danger)"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> Ablehnen</button>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -287,7 +287,7 @@ window.Page_moderation = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs); <div style="margin-bottom:var(--space-2);font-size:var(--text-xs);
color:var(--c-text-muted)">${total} Nutzer gefunden</div> color:var(--c-text-muted)">${total} Nutzer gefunden</div>
<div class="flex-col-gap-2"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${visible.map(u => { ${visible.map(u => {
const isAdminUser = u.rolle === 'admin' || u.is_admin; const isAdminUser = u.rolle === 'admin' || u.is_admin;
const canAction = isAdmin && !isAdminUser; const canAction = isAdmin && !isAdminUser;
@ -299,23 +299,23 @@ window.Page_moderation = (() => {
background:var(--c-surface-2); background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)"> font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${UI.escape(u.name[0].toUpperCase())} ${_esc(u.name[0].toUpperCase())}
</div> </div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)"> color:var(--c-text)">
${UI.escape(u.name)} ${_esc(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px; ${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;
border-radius:3px;background:var(--c-danger); border-radius:3px;background:var(--c-danger);
color:#fff;margin-left:4px">GESPERRT</span>` : ''} color:#fff;margin-left:4px">GESPERRT</span>` : ''}
</div> </div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.escape(u.email)} · ${_esc(u.email)} ·
<span style="color:${ <span style="color:${
u.rolle === 'admin' ? 'var(--c-danger)' u.rolle === 'admin' ? 'var(--c-danger)'
: u.rolle === 'moderator' ? '#f59e0b' : u.rolle === 'moderator' ? '#f59e0b'
: 'var(--c-text-muted)'}"> : 'var(--c-text-muted)'}">
${UI.escape(u.rolle)} ${_esc(u.rolle)}
</span> </span>
</div> </div>
</div> </div>
@ -323,13 +323,13 @@ window.Page_moderation = (() => {
${canAction ${canAction
? (u.is_banned ? (u.is_banned
? `<button class="btn btn-sm btn-ghost mod-unban" ? `<button class="btn btn-sm btn-ghost mod-unban"
data-uid="${u.id}" data-name="${UI.escape(u.name)}" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" class="text-success"> title="Sperre aufheben" style="color:var(--c-success)">
${UI.icon('lock-open')} ${UI.icon('lock-open')}
</button>` </button>`
: `<button class="btn btn-sm btn-ghost mod-ban" : `<button class="btn btn-sm btn-ghost mod-ban"
data-uid="${u.id}" data-name="${UI.escape(u.name)}" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" class="text-danger"> title="Sperren" style="color:var(--c-danger)">
${UI.icon('lock')} ${UI.icon('lock')}
</button>`) </button>`)
: '' : ''
@ -400,27 +400,27 @@ window.Page_moderation = (() => {
return; return;
} }
el.innerHTML = ` el.innerHTML = `
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${reports.map(r => ` ${reports.map(r => `
<div class="card" style="padding:var(--space-4); <div class="card" style="padding:var(--space-4);
border-left:3px solid var(--c-danger)"> border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)"> <div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted); <div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-1)"> margin-bottom:var(--space-1)">
${UI.escape(r.target_type)} #${r.target_id} · ${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${UI.escape(r.melder_name)}</strong> Gemeldet von <strong>${_esc(r.melder_name)}</strong>
</div> </div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)"> color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${UI.escape(r.grund)} Grund: ${_esc(r.grund)}
</div> </div>
${r.content_preview ? ` ${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary); <div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3); padding:var(--space-2) var(--space-3);
background:var(--c-surface-2); background:var(--c-surface-2);
border-radius:var(--radius-sm)"> border-radius:var(--radius-sm)">
${UI.escape(r.content_preview)} ${_esc(r.content_preview)}
</div>` : ''} </div>` : ''}
</div> </div>
<button class="btn btn-sm btn-primary mod-resolve-btn" <button class="btn btn-sm btn-primary mod-resolve-btn"
@ -476,14 +476,14 @@ window.Page_moderation = (() => {
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' }; const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
el.innerHTML = ` el.innerHTML = `
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${edits.map(e => ` ${edits.map(e => `
<div class="card p-4" data-edit-id="${e.id}"> <div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
<div> <div>
<div style="font-weight:600">${UI.escape(e.poi_name)}</div> <div style="font-weight:600">${_esc(e.poi_name)}</div>
<div class="text-xs-muted"> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${UI.escape(e.osm_id)} · Feld: ${UI.escape(e.field)} · von ${UI.escape(e.einreicher_name)} OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')} · ${new Date(e.created_at).toLocaleDateString('de-DE')}
</div> </div>
</div> </div>
@ -494,11 +494,11 @@ window.Page_moderation = (() => {
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)"> <div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)"> <div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
<div class="text-sm">${UI.escape(e.old_value) || '<em class="text-muted">leer</em>'}</div> <div style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
</div> </div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)"> <div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${UI.escape(e.new_value)}</div> <div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
</div> </div>
</div> </div>
${e.status === 'pending' ? ` ${e.status === 'pending' ? `
@ -532,6 +532,15 @@ window.Page_moderation = (() => {
}); });
} }
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -88,7 +88,7 @@ window.Page_movies = (() => {
<div class="movies-search-row"> <div class="movies-search-row">
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> <svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="movies-search" class="form-control movies-search-input" <input type="search" id="movies-search" class="form-control movies-search-input"
placeholder="Film, Serie oder Rasse suchen …" value="${UI.escape(_search)}" autocomplete="off"> placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
</div> </div>
<div class="movies-filter-row"> <div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button> <button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
@ -96,7 +96,7 @@ window.Page_movies = (() => {
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button> <button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button> <button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
</div> </div>
<div class="movies-filter-row mt-2"> <div class="movies-filter-row" style="margin-top:var(--space-2)">
<button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button> <button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button> <button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button> <button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button>
@ -201,18 +201,18 @@ window.Page_movies = (() => {
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false); const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
const _ico = name => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:middle"><use href="/icons/phosphor.svg#${name}"></use></svg>`; const _ico = name => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:middle"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : ''; const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : '';
const imdb = film.imdb_rating ? `<span class="text-xs-muted">IMDb ${film.imdb_rating}</span>` : ''; const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
const streaming = film.streaming ? `<span class="text-xs-muted">${UI.escape(film.streaming)}</span>` : ''; const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
return ` return `
<div class="movie-card" data-film-id="${UI.escape(film.id)}"> <div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div> <div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body"> <div class="movie-card-body">
<div class="movie-card-title">${UI.escape(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div> <div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap"> <div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span>${UI.escape(film.genre)}</span>${typLabel ? `<span class="text-xs-muted">${typLabel}</span>` : ''} <span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
</div> </div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${UI.escape(film.hund_rasse)}</div> <div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
${tag} ${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div> <div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div> <div class="movie-card-stars">${stars}</div>
@ -234,17 +234,17 @@ window.Page_movies = (() => {
const body = ` const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div> <div class="movie-modal-emoji">${film.bild_emoji}</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${UI.escape(film.genre)}</span> <span class="badge badge-primary">${_esc(film.genre)}</span>
<span class="badge"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${UI.escape(film.hund_rasse)}</span> <span class="badge"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span> <span class="badge">${film.jahr}</span>
</div> </div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div> <div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${UI.escape(film.beschreibung)}</p> <p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div class="mb-2"> <div style="margin-bottom:var(--space-2)">
<strong>Community-Bewertung:</strong> <strong>Community-Bewertung:</strong>
</div> </div>
<div id="modal-stars-${UI.escape(film.id)}">${stars}</div> <div id="modal-stars-${_esc(film.id)}">${stars}</div>
<div id="modal-avg-${UI.escape(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"> <div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div> </div>
${loginHint} ${loginHint}
@ -262,9 +262,9 @@ window.Page_movies = (() => {
const filled = Math.round(avg); const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => { const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : ''; const active = i <= (userRating || filled) ? ' movie-star--active' : '';
return `<span class="movie-star${active}" data-film-id="${UI.escape(filmId)}" data-val="${i}"><svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px"><use href="/icons/phosphor.svg#star"></use></svg></span>`; return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}"><svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px"><use href="/icons/phosphor.svg#star"></use></svg></span>`;
}).join(''); }).join('');
return `<div class="movie-star-rating" data-film-id="${UI.escape(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`; return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
} }
function _bindStarRatings(container) { function _bindStarRatings(container) {
@ -339,9 +339,9 @@ window.Page_movies = (() => {
<div class="movie-promi-card"> <div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div> <div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body"> <div class="movie-promi-body">
<div class="movie-promi-name">${UI.escape(p.name)}</div> <div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${UI.escape(p.rasse)}</div> <div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${UI.escape(p.bekannt_fuer)}</div> <div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -370,13 +370,13 @@ window.Page_movies = (() => {
const voteCards = _appState.dogs.map(dog => { const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id; const isVoted = data.user_vote === dog.id;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">` ? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return ` return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}"> <div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div> <div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${UI.escape(dog.name)}</div> <div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}"> <button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Gewählt' : 'Abstimmen'} ${isVoted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Gewählt' : 'Abstimmen'}
</button> </button>
@ -405,16 +405,16 @@ window.Page_movies = (() => {
? data.top.slice(0, 5).map((dog, i) => { ? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`; const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">` ? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-top-entry"> <div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span> <span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div> <div class="hdm-top-av">${av}</div>
<div class="hdm-top-info"> <div class="hdm-top-info">
<div class="hdm-top-name">${UI.escape(dog.name)}</div> <div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div> </div>
<div class="hdm-top-stimmen">${dog.stimmen} <svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg></div> <div class="hdm-top-stimmen">${dog.stimmen} <svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg></div>
@ -427,7 +427,7 @@ window.Page_movies = (() => {
<div class="hdm-header"> <div class="hdm-header">
<div class="hdm-trophy">🏆</div> <div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2> <h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${UI.escape(monthName)}</div> <div class="hdm-monat">${_esc(monthName)}</div>
</div> </div>
${voteSection} ${voteSection}
@ -465,7 +465,16 @@ window.Page_movies = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// HELPER // HELPER
// ---------------------------------------------------------- // ----------------------------------------------------------
// ---------------------------------------------------------- function _esc(str) {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh }; return { init, refresh };

View file

@ -47,6 +47,14 @@ window.Page_notes = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Hilfsfunktionen // Hilfsfunktionen
// ---------------------------------------------------------- // ----------------------------------------------------------
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _formatTime(isoStr) { function _formatTime(isoStr) {
if (!isoStr) return ''; if (!isoStr) return '';
@ -68,10 +76,7 @@ window.Page_notes = (() => {
} catch (_) { return 'Älteres'; } } catch (_) { return 'Älteres'; }
} }
function _truncate(str, max = 600) { function _truncate(str, max = 150) {
// Karten zeigen max 5 Zeilen via CSS-Clamp — Text muss lang genug
// sein dass die Clamp greift. Bei sehr langen Notes: vor Clamp abschneiden
// damit der String nicht riesig in der DOM-Page steht.
if (!str) return ''; if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str; return str.length > max ? str.slice(0, max) + '…' : str;
} }
@ -120,7 +125,7 @@ window.Page_notes = (() => {
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([label, items]) => ` .map(([label, items]) => `
<div class="notes-group"> <div class="notes-group">
<div class="list-group-header">${UI.escape(label)}</div> <div class="notes-group-label">${_esc(label)}</div>
${items.map(_noteCard).join('')} ${items.map(_noteCard).join('')}
</div> </div>
`).join(''); `).join('');
@ -161,9 +166,9 @@ window.Page_notes = (() => {
<div class="notes-filter-chips"> <div class="notes-filter-chips">
${RUBRIKEN.map(r => ` ${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}" <button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
data-type="${UI.escape(r.type)}" data-type="${_esc(r.type)}"
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}"> style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${UI.escape(r.label)} ${_esc(r.label)}
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -173,7 +178,7 @@ window.Page_notes = (() => {
<div class="notes-search-wrap"> <div class="notes-search-wrap">
<svg class="ph-icon notes-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> <svg class="ph-icon notes-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input id="notes-search" type="search" class="notes-search-input" <input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${UI.escape(_searchQ)}"> placeholder="Suche…" value="${_esc(_searchQ)}">
</div> </div>
<div class="notes-sort-btns"> <div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}" <button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
@ -238,32 +243,21 @@ window.Page_notes = (() => {
/* Gruppen */ /* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); } .notes-group { display: flex; flex-direction: column; gap: var(--space-2); }
/* TODO nach Migration entfernen: ersetzt durch .list-group-header in lists.css */
.notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; } .notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; }
/* Karten — Notes-spezifischer Override: vertikales Layout statt horizontalem .list-item-card */ /* Karten */
.notes-card { flex-direction: column; gap: var(--space-2); } .notes-card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; } .notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); }
/* TODO nach Migration entfernen: ersetzt durch .list-item-chip */ .notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; }
/* .notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; } */
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; } .notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-meta-row */ .notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); }
/* .notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); } */ .notes-card-actions { display: flex; gap: var(--space-2); margin-left: auto; flex-shrink: 0; }
/* Notes-Override: Actions in Top-Zeile rechts ausrichten (statt align-self:center bei list-item-actions) */ .notes-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; }
.notes-card-actions { margin-left: auto; align-self: flex-start; } .notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); }
/* Notes-Override: Newlines (pre-wrap) + max 5 Zeilen mit "…", Rest in Detail-Modal */ .notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); }
.notes-card-text { line-height: 1.55; white-space: pre-wrap; margin: 0; color: var(--c-text); .notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; }
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; } .notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); }
/* Detail-Modal: voller Notiz-Text scrollbar */ .notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); }
.notes-detail-text { white-space: pre-wrap; line-height: 1.6; font-size: var(--text-base);
color: var(--c-text); margin: 0; max-height: 60vh; overflow-y: auto; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-micro-badges / .list-item-micro-badge */
/* .notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); } */
/* .notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); } */
/* TODO nach Migration entfernen: ersetzt durch .list-item-action-btn / --danger */
/* .notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; } */
/* .notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); } */
/* .notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); } */
.notes-list { display: flex; flex-direction: column; gap: var(--space-4); } .notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@ -291,11 +285,11 @@ window.Page_notes = (() => {
<button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}> <button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}>
${_kiLoading ? '<svg class="ph-icon" aria-hidden="true" style="animation:spin 1s linear infinite"><use href="/icons/phosphor.svg#spinner-gap"></use></svg> Analysiere…' : 'Analysieren'} ${_kiLoading ? '<svg class="ph-icon" aria-hidden="true" style="animation:spin 1s linear infinite"><use href="/icons/phosphor.svg#spinner-gap"></use></svg> Analysiere…' : 'Analysieren'}
</button> </button>
${_kiError ? `<div class="notes-ki-error"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg> ${UI.escape(_kiError)}</div>` : ''} ${_kiError ? `<div class="notes-ki-error"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg> ${_esc(_kiError)}</div>` : ''}
${_kiSuggestions ? ` ${_kiSuggestions ? `
<div class="notes-ki-suggestions"> <div class="notes-ki-suggestions">
<ul> <ul>
${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')} ${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
</ul> </ul>
</div> </div>
` : ''} ` : ''}
@ -320,42 +314,43 @@ window.Page_notes = (() => {
const hasLocation = !!note.location_name; const hasLocation = !!note.location_name;
return ` return `
<div class="list-item-card list-item-card--clickable notes-card" data-id="${note.id}"> <div class="notes-card" data-id="${note.id}">
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons --> <!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top"> <div class="notes-card-top">
<span class="list-item-chip" style="--chip-color:${rb.color}"> <span class="notes-rubrik-chip"
style="background:${rb.color}22;color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg>
${UI.escape(rb.label)} ${_esc(rb.label)}
</span> </span>
${note.parent_label ${note.parent_label
? `<span class="notes-parent-label" title="${UI.escape(note.parent_label)}">${UI.escape(note.parent_label)}</span>` ? `<span class="notes-parent-label" title="${_esc(note.parent_label)}">${_esc(note.parent_label)}</span>`
: '' : ''
} }
<div class="list-item-actions notes-card-actions"> <div class="notes-card-actions">
<button class="list-item-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten"> <button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button> </button>
<button class="list-item-action-btn list-item-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen"> <button class="notes-action-btn notes-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button> </button>
</div> </div>
</div> </div>
<!-- Notiztext --> <!-- Notiztext -->
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p> <p class="notes-card-text">${_esc(_truncate(note.text))}</p>
<!-- Micro-Badges --> <!-- Micro-Badges -->
${microBadges.length ? ` ${microBadges.length ? `
<div class="list-item-micro-badges"> <div class="notes-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')} ${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
</div> </div>
` : ''} ` : ''}
<!-- Meta: Zeit + Ort --> <!-- Meta: Zeit + Ort -->
<div class="list-item-meta-row"> <div class="notes-card-meta">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> <svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg>
${UI.escape(_formatTime(note.updated_at || note.created_at))} ${_esc(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''} ${hasLocation ? `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${_esc(note.location_name)}` : ''}
</div> </div>
</div> </div>
`; `;
@ -465,64 +460,6 @@ window.Page_notes = (() => {
} }
}); });
}); });
// Karte selbst klickbar → Detail-Modal mit vollem Text
_container.querySelectorAll('.notes-card').forEach(card => {
card.addEventListener('click', e => {
// Klicks auf Action-Buttons nicht doppelt verarbeiten
if (e.target.closest('.list-item-action-btn')) return;
const note = _notes.find(n => n.id === parseInt(card.dataset.id, 10));
if (note) _openDetailModal(note);
});
});
}
// ----------------------------------------------------------
// Detail-Modal: voller Notiz-Text + Meta + Bearbeiten/Löschen
// ----------------------------------------------------------
function _openDetailModal(note) {
const rb = RUBRIKEN.find(r => r.id === note.rubrik) || RUBRIKEN[0];
const meta = (() => { try { return JSON.parse(note.meta || '{}'); } catch { return {}; } })();
const microBadges = [];
if (meta.erfolg) microBadges.push(`🐾 ${meta.erfolg}/5`);
if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung);
if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung);
UI.modal.open({
title: `${UI.icon(rb.icon)} ${UI.escape(rb.label)}`,
body: `
<div class="flex-col-gap-3">
${note.parent_label
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
<p class="notes-detail-text">${UI.escape(note.text || '')}</p>
${microBadges.length ? `
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div>` : ''}
<div class="list-item-meta-row" style="margin-top:var(--space-2)">
<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
${UI.escape(_formatTime(note.updated_at || note.created_at))}
${note.location_name
? `<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''}
</div>
</div>
`,
footer: `
<div class="flex-gap-2" style="width:100%">
<button class="btn btn-ghost flex-1" id="notes-detail-edit">
${UI.icon('pencil')} Bearbeiten
</button>
<button class="btn btn-secondary" data-modal-close>Schließen</button>
</div>
`,
});
document.getElementById('notes-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_openEditModal(note);
});
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -562,7 +499,7 @@ window.Page_notes = (() => {
<h3 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">Neue Notiz</h3> <h3 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">Neue Notiz</h3>
<!-- Kategorie-Auswahl --> <!-- Kategorie-Auswahl -->
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Kategorie</label> <label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Kategorie</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${ERSTELL_RUBRIKEN.map(r => ` ${ERSTELL_RUBRIKEN.map(r => `
@ -571,13 +508,13 @@ window.Page_notes = (() => {
border-radius:999px;border:1.5px solid ${_selType===r.type ? r.color : 'var(--c-border)'}; border-radius:999px;border:1.5px solid ${_selType===r.type ? r.color : 'var(--c-border)'};
background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'}; background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'};
color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer"> color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer">
${UI.escape(r.label)} ${_esc(r.label)}
</button>`).join('')} </button>`).join('')}
</div> </div>
</div> </div>
<!-- Text --> <!-- Text -->
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label> <label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="nc-text" rows="5" placeholder="Was möchtest du festhalten…" <textarea id="nc-text" rows="5" placeholder="Was möchtest du festhalten…"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border); style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
@ -587,9 +524,9 @@ window.Page_notes = (() => {
box-sizing:border-box"></textarea> box-sizing:border-box"></textarea>
</div> </div>
<div class="flex-gap-3"> <div style="display:flex;gap:var(--space-3)">
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button> <button id="nc-cancel" class="btn btn-ghost" style="flex:1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button> <button id="nc-save" class="btn btn-primary" style="flex:1">Speichern</button>
</div> </div>
</div>`; </div>`;
}; };
@ -664,7 +601,7 @@ window.Page_notes = (() => {
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs); <span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);
font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px; font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}"> background:${rb.color}22;color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${UI.escape(rb.label)} <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${_esc(rb.label)}
</span> </span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0"> <h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten Notiz bearbeiten
@ -682,7 +619,7 @@ window.Page_notes = (() => {
border-radius:var(--radius-md);font-size:var(--text-sm); border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface); font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5; color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box">${UI.escape(note.text)}</textarea> box-sizing:border-box">${_esc(note.text)}</textarea>
</div> </div>
${note.parent_type === 'training_session' ? ` ${note.parent_type === 'training_session' ? `
@ -690,7 +627,7 @@ window.Page_notes = (() => {
<div> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label> color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => ` ${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${n}" <button type="button" class="notes-pfote" data-val="${n}"
style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md); style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
@ -705,7 +642,7 @@ window.Page_notes = (() => {
<div> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label> color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => ` ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${val}" <button type="button" class="notes-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md); style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
@ -720,7 +657,7 @@ window.Page_notes = (() => {
<div> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label> color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => ` ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${val}" <button type="button" class="notes-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md); style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);

View file

@ -85,7 +85,7 @@ window.Page_onboarding = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _step1() { function _step1() {
return ` return `
<div class="text-center"> <div style="text-align:center">
<!-- Logo --> <!-- Logo -->
<div style="margin-bottom:var(--space-6)"> <div style="margin-bottom:var(--space-6)">
@ -133,19 +133,19 @@ window.Page_onboarding = (() => {
<div> <div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${title}</div> color:var(--c-text)">${title}</div>
<div class="text-xs-secondary">${desc}</div> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${desc}</div>
</div> </div>
</div> </div>
`).join('')} `).join('')}
</div> </div>
<!-- Buttons --> <!-- Buttons -->
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="ob-next-btn" class="w-full"> <button class="btn btn-primary" id="ob-next-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-right"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-right"></use></svg>
Los geht's Los geht's
</button> </button>
<button class="btn btn-ghost" id="ob-skip-btn" class="w-full"> <button class="btn btn-ghost" id="ob-skip-btn" style="width:100%">
Überspringen Überspringen
</button> </button>
</div> </div>
@ -222,7 +222,7 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<span id="ob-photo-label">Foto auswählen</span> <span id="ob-photo-label">Foto auswählen</span>
<input type="file" name="foto" id="ob-photo-input" <input type="file" name="foto" id="ob-photo-input"
accept="image/*" class="hidden"> accept="image/*" style="display:none">
</label> </label>
</div> </div>
@ -234,13 +234,13 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button> </button>
<button type="submit" form="ob-dog-form" class="btn btn-primary" id="ob-save-btn" <button type="submit" form="ob-dog-form" class="btn btn-primary" id="ob-save-btn"
class="flex-1"> style="flex:1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Hund anlegen Hund anlegen
</button> </button>
</div> </div>
<div style="text-align:center;margin-top:var(--space-3)"> <div style="text-align:center;margin-top:var(--space-3)">
<button class="btn btn-ghost" id="ob-skip-btn" class="text-sm"> <button class="btn btn-ghost" id="ob-skip-btn" style="font-size:var(--text-sm)">
Ohne Hund fortfahren Ohne Hund fortfahren
</button> </button>
</div> </div>
@ -255,7 +255,7 @@ window.Page_onboarding = (() => {
function _step3() { function _step3() {
const dogName = _appState.activeDog?.name; const dogName = _appState.activeDog?.name;
return ` return `
<div class="text-center"> <div style="text-align:center">
<!-- Erfolgs-Icon --> <!-- Erfolgs-Icon -->
<div style="margin-bottom:var(--space-6)"> <div style="margin-bottom:var(--space-6)">
@ -276,7 +276,7 @@ window.Page_onboarding = (() => {
${dogName ? ` ${dogName ? `
<p style="font-size:var(--text-base);color:var(--c-text-secondary); <p style="font-size:var(--text-base);color:var(--c-text-secondary);
line-height:1.6;margin:0 0 var(--space-3)"> line-height:1.6;margin:0 0 var(--space-3)">
<strong>${UI.escape(dogName)}</strong> ist jetzt in Ban Yaro. <strong>${_esc(dogName)}</strong> ist jetzt in Ban Yaro.
Du kannst jetzt Einträge im Tagebuch anlegen, die Gesundheit pflegen Du kannst jetzt Einträge im Tagebuch anlegen, die Gesundheit pflegen
und viele weitere Funktionen nutzen. und viele weitere Funktionen nutzen.
</p> </p>
@ -294,13 +294,13 @@ window.Page_onboarding = (() => {
</p> </p>
<!-- CTA --> <!-- CTA -->
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="ob-diary-btn" class="w-full"> <button class="btn btn-primary" id="ob-diary-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
Zum Tagebuch Zum Tagebuch
</button> </button>
${dogName ? ` ${dogName ? `
<button class="btn btn-secondary" id="ob-profile-btn" class="w-full"> <button class="btn btn-secondary" id="ob-profile-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Profil vervollständigen Profil vervollständigen
</button> </button>
@ -416,7 +416,7 @@ window.Page_onboarding = (() => {
} }
App.renderDogSwitcher(); App.renderDogSwitcher();
UI.toast.success(`${UI.escape(dog.name)} wurde angelegt!`); UI.toast.success(`${_esc(dog.name)} wurde angelegt!`);
_step = 3; _step = 3;
_render(); _render();
@ -452,6 +452,9 @@ window.Page_onboarding = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// HELPER // HELPER
// ---------------------------------------------------------- // ----------------------------------------------------------
function _esc(s) {
return UI.escape(s || '');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC

View file

@ -1,274 +0,0 @@
/* ============================================================
BAN YARO Partner-Profil-Editor
Nur für User mit is_partner=1.
============================================================ */
window.Page_partner_profil = (() => {
let _container = null;
let _profile = null;
async function init(container, appState) {
_container = container;
_render();
await _load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div style="margin-bottom:var(--space-5)">
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
Mein Partner-Profil
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Richte deine öffentliche Präsenz auf der Partner-Seite ein.
Nach dem Absenden prüfen wir dein Profil und schalten es frei.
</p>
</div>
<div id="pp-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#pp-content');
try {
const d = await API.get('/partner/my-profile');
_profile = d.profile || {};
_profile._storage_mb = d.storage_mb || 0;
_profile._storage_limit_mb = d.storage_limit_mb || 200;
el.innerHTML = _renderEditor();
_bindEvents(el);
} catch (e) {
el.innerHTML = `<p class="text-danger">${e.message}</p>`;
}
}
function _statusBadge() {
if (!_profile?.submitted_at && !_profile?.approved) return '';
const a = _profile.approved;
if (a === 1) return `<span style="background:#dcfce7;color:#16a34a;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✓ Freigegeben</span>`;
if (a === -1) return `<span style="background:#fee2e2;color:#dc2626;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✗ Abgelehnt</span>`;
if (_profile.submitted_at) return `<span style="background:#fef9c3;color:#a16207;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">⏳ In Prüfung</span>`;
return `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Entwurf</span>`;
}
function _renderEditor() {
const p = _profile || {};
const photos = p.photos || [];
return `
<!-- Status -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<span class="text-sm-muted">Status:</span>
${_statusBadge() || '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Noch kein Profil angelegt</span>'}
</div>
<!-- Logo -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Logo</div>
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="pp-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;
overflow:hidden;flex-shrink:0">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:100%;height:100%;object-fit:contain">`
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
</div>
<div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
Logo hochladen
<input type="file" id="pp-logo-input" accept="image/*" class="hidden">
</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
PNG, JPG oder WebP · max. 5 MB · wird quadratisch zugeschnitten
</div>
</div>
</div>
</div>
<!-- Texte -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Texte</div>
<form id="pp-text-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Anzeigename *</label>
<input class="form-control" name="display_name" type="text" maxlength="60" required
placeholder="z. B. Hundeblog Musterfrau"
value="${UI.escape(p.display_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Kurzslogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
<input class="form-control" name="tagline" type="text" maxlength="80"
placeholder="z. B. Hundetrainerin · 15.000 Follower auf Instagram"
value="${UI.escape(p.tagline || '')}">
</div>
<div class="form-group">
<label class="form-label">Über dich / euer Kanal</label>
<textarea class="form-control" name="bio" rows="4" maxlength="500"
placeholder="Wer bist du, was machst du, was verbindet dich mit Hunden?">${UI.escape(p.bio || p.pp_bio || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url"
placeholder="https://deine-seite.de"
value="${UI.escape(p.website || '')}">
</div>
<div class="form-group">
<label class="form-label">Instagram</label>
<input class="form-control" name="instagram" type="text"
placeholder="@deinkanal"
value="${UI.escape(p.instagram || '')}">
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
Texte speichern
</button>
</form>
</div>
<!-- Fotos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
Fotos & Videos <span style="font-weight:400">(max. 6)</span>
</div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
</div>
<div class="mb-3">
${_storageBar(p._storage_mb || 0, p._storage_limit_mb || 200)}
</div>
<div id="pp-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);
gap:var(--space-2);margin-bottom:var(--space-3)">
${photos.map((url, i) => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return `
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);
border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover">`}
<button class="pp-photo-del" data-idx="${i}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">
×
</button>
</div>`;
}).join('')}
${photos.length < 6 ? `
<label style="aspect-ratio:1;border-radius:var(--radius-md);border:2px dashed var(--c-border);
display:flex;align-items:center;justify-content:center;cursor:pointer;
color:var(--c-text-muted);flex-direction:column;gap:4px">
<svg class="ph-icon" style="width:24px;height:24px"><use href="/icons/phosphor.svg#plus"></use></svg>
<span style="font-size:10px">Foto</span>
<input type="file" id="pp-photo-input" accept="image/*,video/*" class="hidden">
</label>` : ''}
</div>
</div>
<!-- Absenden -->
<div style="display:flex;gap:var(--space-3);justify-content:flex-end;margin-top:var(--space-4)">
<button id="pp-submit-btn" class="btn btn-primary">
Zur Freigabe einreichen
</button>
</div>
`;
}
function _bindEvents(el) {
// Logo hochladen
el.querySelector('#pp-logo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const r = await API.upload('/partner/my-profile/logo', fd);
el.querySelector('#pp-logo-preview').innerHTML =
`<img src="${UI.escape(r.logo_url)}" style="width:100%;height:100%;object-fit:contain">`;
_profile = { ..._profile, logo_url: r.logo_url };
UI.toast.success('Logo gespeichert.');
} catch (err) { UI.toast.error(err.message); }
});
// Texte speichern
el.querySelector('#pp-text-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.put('/partner/my-profile', fd);
_profile = { ..._profile, ...fd };
UI.toast.success('Gespeichert.');
});
});
// Foto/Video hochladen
el.querySelector('#pp-photo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const fd = new FormData();
fd.append('file', file);
if (isVideo) UI.toast.info('Video wird hochgeladen und komprimiert das kann 12 Minuten dauern …', 120_000);
try {
const r = await API.upload('/partner/my-profile/photos', fd);
_profile = { ..._profile, photos: r.photos };
await _load();
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto löschen
el.querySelectorAll('.pp-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx);
try {
const r = await API.post(`/partner/my-profile/photos/${idx}/delete`, {});
_profile = { ..._profile, photos: r.photos };
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Einreichen
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
const btn = el.querySelector('#pp-submit-btn');
await UI.asyncButton(btn, async () => {
await API.post('/partner/my-profile/submit', {});
UI.toast.success('Eingereicht! Wir prüfen dein Profil und schalten es bald frei.');
await _load();
});
});
}
function _storageBar(usedMb, limitMb) {
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px;transition:width .4s"></div>
</div>
<span style="white-space:nowrap;color:${pct > 85 ? '#dc2626' : 'var(--c-text-muted)'}">
${usedMb.toFixed(1)} / ${limitMb} MB
</span>
</div>`;
}
return { init, refresh, onDogChange };
})();

View file

@ -1,149 +0,0 @@
/* ============================================================
BAN YARO Partner-Seite
Showcase der offiziellen Ban Yaro Partner.
============================================================ */
window.Page_partner = (() => {
let _container = null;
async function init(container) {
_container = container;
_render();
_load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-2)">🤝</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Unsere Partner
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:480px;margin:0 auto">
Diese Menschen glauben an Ban Yaro und helfen uns, die Community zu wachsen.
Über ihre persönlichen Einladungscodes können sie neue Gründer vermitteln.
</p>
</div>
<div id="partner-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#partner-content');
try {
const d = await API.get('/partners/public');
if (!d?.partners) throw new Error('Keine Daten.');
el.innerHTML = _renderPartners(d.partners);
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-muted);text-align:center">${e.message || 'Fehler beim Laden.'}</p>`;
}
}
function _renderPartners(partners) {
if (!partners.length) {
return `
<div class="by-card" style="padding:var(--space-6);text-align:center">
<p class="text-sm-muted">
Noch keine Partner das könnte schon bald du sein.
</p>
</div>
${_cta()}
`;
}
const COLORS = [
'linear-gradient(135deg,#7c3aed,#a855f7)',
'linear-gradient(135deg,#2563eb,#3b82f6)',
'linear-gradient(135deg,#059669,#10b981)',
'linear-gradient(135deg,#d97706,#f59e0b)',
'linear-gradient(135deg,#db2777,#ec4899)',
];
return `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);margin-bottom:var(--space-5)">
${partners.map((p, i) => {
const initial = (p.name || '?')[0].toUpperCase();
const grad = COLORS[i % COLORS.length];
return `
<div class="by-card" style="padding:var(--space-4);position:relative;overflow:hidden">
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:${grad}"></div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" alt=""
style="width:56px;height:56px;border-radius:var(--radius-md);object-fit:contain;flex-shrink:0;background:var(--c-surface-2);padding:4px">`
: p.avatar_url
? `<img src="${UI.escape(p.avatar_url)}" alt=""
style="width:56px;height:56px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<div style="width:56px;height:56px;border-radius:50%;flex-shrink:0;
background:${grad};display:flex;align-items:center;
justify-content:center;font-size:24px;font-weight:800;color:#fff">
${initial}
</div>`
}
<div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(p.display_name || p.name)}</div>
${p.tagline ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:1px">${UI.escape(p.tagline)}</div>` : ''}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)">
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener"
style="font-size:var(--text-xs);color:var(--c-primary)">
🌐 ${UI.escape(p.website.replace(/^https?:\/\//, ''))}</a>` : ''}
${p.instagram ? `<span class="text-xs-muted">📸 ${UI.escape(p.instagram)}</span>` : ''}
</div>
</div>
</div>
${p.pp_bio || p.bio ? `<p style="margin:var(--space-3) 0 0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.5">
${UI.escape(p.pp_bio || p.bio)}
</p>` : ''}
${p.photos?.length ? `
<div style="display:grid;grid-template-columns:repeat(${Math.min(p.photos.length,3)},1fr);
gap:var(--space-1);margin-top:var(--space-3);border-radius:var(--radius-md);overflow:hidden">
${p.photos.slice(0,3).map(url => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return isVid
? `<video src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover"
muted playsinline loop autoplay></video>`
: `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover">`;
}).join('')}
</div>` : ''}
</div>
`;
}).join('')}
</div>
${_cta()}
`;
}
function _cta() {
return `
<div class="by-card" style="padding:var(--space-5);text-align:center;
background:linear-gradient(135deg,rgba(124,58,237,.08),rgba(168,85,247,.08));
border:1px solid rgba(124,58,237,.2)">
<div style="font-size:var(--text-base);font-weight:700;margin-bottom:var(--space-2)">
Du möchtest Partner werden?
</div>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Schreib uns wir richten deinen persönlichen Einladungscode ein.
</p>
<a href="mailto:partner@banyaro.app?subject=Ban Yaro Partner"
style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#7c3aed,#a855f7);
color:#fff;border-radius:var(--radius-full);font-weight:700;
font-size:var(--text-sm);text-decoration:none">
📧 partner@banyaro.app
</a>
</div>
`;
}
return { init, refresh, onDogChange };
})();

View file

@ -237,7 +237,7 @@ window.Page_personality = (() => {
<!-- Fortschritt --> <!-- Fortschritt -->
<div style="padding:var(--space-4) var(--space-4) 0"> <div style="padding:var(--space-4) var(--space-4) 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span class="text-xs-muted"> <span style="font-size:var(--text-xs);color:var(--c-text-muted)">
Frage ${_current + 1} von ${FRAGEN.length} Frage ${_current + 1} von ${FRAGEN.length}
</span> </span>
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</span> <span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</span>
@ -344,7 +344,7 @@ window.Page_personality = (() => {
return ` return `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:1rem;width:24px;text-align:center">${tp.emoji}</span> <span style="font-size:1rem;width:24px;text-align:center">${tp.emoji}</span>
<div class="flex-1"> <div style="flex:1">
<div style="height:8px;background:var(--c-border);border-radius:4px;overflow:hidden"> <div style="height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div> <div style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div>
</div> </div>
@ -414,7 +414,7 @@ window.Page_personality = (() => {
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600; <div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em; color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Dein Profil</div> border-bottom:1px solid var(--c-border)">Dein Profil</div>
<div class="p-4">${scoreBars}</div> <div style="padding:var(--space-4)">${scoreBars}</div>
</div> </div>
<!-- Teilen + Nochmal --> <!-- Teilen + Nochmal -->

View file

@ -281,8 +281,8 @@ window.Page_places = (() => {
</div> </div>
</div> </div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${UI.escape(place.adresse)}</p>` : ''} ${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${UI.escape(place.adresse)}</p>` : ''}
${place.telefon ? `<p class="mb-2"><a href="tel:${UI.escape(place.telefon)}" class="text-primary">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''} ${place.telefon ? `<p style="margin-bottom:var(--space-2)"><a href="tel:${UI.escape(place.telefon)}" style="color:var(--c-primary)">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''}
${place.website ? `<p class="mb-2"><a href="${UI.escape(place.website)}" target="_blank" class="text-primary">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''} ${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${UI.escape(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''} ${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<div id="place-rating-${place.id}"></div> <div id="place-rating-${place.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)"> <p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
@ -291,7 +291,7 @@ window.Page_places = (() => {
`; `;
const footer = isOwn ? ` const footer = isOwn ? `
<button type="button" class="btn btn-secondary w-full" id="place-detail-edit">Bearbeiten</button> <button type="button" class="btn btn-secondary" style="width:100%" id="place-detail-edit">Bearbeiten</button>
<button type="button" class="btn btn-ghost" style="width:100%;margin-top:var(--space-2)" id="place-detail-close">Schließen</button> <button type="button" class="btn btn-ghost" style="width:100%;margin-top:var(--space-2)" id="place-detail-close">Schließen</button>
` : ` ` : `
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button> <button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
@ -348,24 +348,24 @@ window.Page_places = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Adresse <span class="text-secondary">(optional)</span></label> <label class="form-label">Adresse <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="adresse" <input class="form-control" type="text" name="adresse"
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt"> value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Website <span class="text-secondary">(optional)</span></label> <label class="form-label">Website <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="url" name="website" <input class="form-control" type="url" name="website"
value="${UI.escape(place?.website || '')}" placeholder="https://…"> value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Telefon <span class="text-secondary">(optional)</span></label> <label class="form-label">Telefon <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="tel" name="telefon" <input class="form-control" type="tel" name="telefon"
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456"> value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</div> </div>
<div class="form-group flex-col-gap-2"> <div class="form-group" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label class="form-label">Hundefreundlichkeit</label> <label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer"> <label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}> <input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
@ -386,10 +386,10 @@ window.Page_places = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="place-form" class="btn btn-primary w-full"> <button type="submit" form="place-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Ort hinzufügen'} ${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button> </button>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="place-form-delete">Löschen</button>` : ''} ${isEdit ? `<button type="button" class="btn btn-danger" id="place-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
</div> </div>

View file

@ -15,16 +15,21 @@ window.Page_playdate = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function _fmtDate(iso) { function _esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
if (!iso) return ''; if (!iso) return '';
const d = new Date(iso.replace(' ', 'T')); const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE'); return d.toLocaleDateString('de-DE');
} }
function _dogAvatar(foto_url, name, size = 48) { function _dogAvatar(foto_url, name, size = 48) {
const initials = UI.escape((name || '?').charAt(0).toUpperCase()); const initials = _esc((name || '?').charAt(0).toUpperCase());
if (foto_url) { if (foto_url) {
return `<img src="${UI.escape(foto_url)}" alt="${initials}" return `<img src="${_esc(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;" style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`; onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
} }
@ -81,7 +86,7 @@ function _fmtDate(iso) {
<div class="playdate-layout"> <div class="playdate-layout">
<!-- Tabs --> <!-- Tabs -->
<div class="by-tabs" id="playdate-tabs" class="mb-4"> <div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
<button class="by-tab active" data-tab="nearby">In der Nähe</button> <button class="by-tab active" data-tab="nearby">In der Nähe</button>
<button class="by-tab" data-tab="listings">Meine Inserate</button> <button class="by-tab" data-tab="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests"> <button class="by-tab" data-tab="requests">
@ -128,7 +133,7 @@ function _fmtDate(iso) {
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('map-pin')} ${UI.icon('map-pin')}
<span class="text-sm-secondary" id="nearby-location-label"> <span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
${_userPos ? 'Standort bekannt' : 'Kein Standort'} ${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span> </span>
</div> </div>
@ -240,34 +245,34 @@ function _fmtDate(iso) {
function _nearbyCard(d) { function _nearbyCard(d) {
return ` return `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(d.foto_url, d.dog_name, 56)} ${_dogAvatar(d.foto_url, d.dog_name, 56)}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base); <div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
color:var(--c-text)">${UI.escape(d.dog_name)}</div> color:var(--c-text)">${_esc(d.dog_name)}</div>
${d.rasse ? `<div class="text-sm-secondary">${UI.escape(d.rasse)}</div>` : ''} ${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
${d.alter ? `<div class="text-xs-muted">${UI.escape(d.alter)}</div>` : ''} ${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
</div> </div>
</div> </div>
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)"> <span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.icon('map-pin')} ${UI.icon('map-pin')}
${d.ort_name ? UI.escape(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt ${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
</span> </span>
${d.geschlecht ? `<span class="text-xs-muted">${UI.escape(d.geschlecht)}</span>` : ''} ${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
</div> </div>
${d.beschreibung ? ` ${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary); <p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5"> margin:0 0 var(--space-3);line-height:1.5">
${UI.escape(d.beschreibung)} ${_esc(d.beschreibung)}
</p>` : ''} </p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn" <button class="btn btn-primary btn-sm playdate-anfrage-btn"
data-dog-id="${d.dog_id}" data-dog-id="${d.dog_id}"
data-dog-name="${UI.escape(d.dog_name)}"> data-dog-name="${_esc(d.dog_name)}">
${UI.icon('paw-print')} Spielkamerad anfragen ${UI.icon('paw-print')} Spielkamerad anfragen
</button> </button>
</div> </div>
@ -384,12 +389,12 @@ function _fmtDate(iso) {
function _listingCard(dog, listing) { function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv; const isAktiv = listing && listing.aktiv;
return ` return `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)} ${_dogAvatar(dog.foto_url, dog.name, 44)}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(dog.name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="text-xs-secondary">${UI.escape(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
</div> </div>
<span style="font-size:var(--text-xs);font-weight:600; <span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px; padding:2px 10px;border-radius:999px;
@ -402,12 +407,12 @@ function _fmtDate(iso) {
${isAktiv ? ` ${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')} ${UI.icon('map-pin')}
${listing.ort_name ? UI.escape(listing.ort_name) + ' · ' : ''} ${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km Radius: ${listing.radius_km} km
</div> </div>
${listing.beschreibung ? ` ${listing.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary); <p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">${UI.escape(listing.beschreibung)}</p>` : ''} margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
` : ` ` : `
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)"> <p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Noch kein Inserat trage dich ein, damit andere dich finden können. Noch kein Inserat trage dich ein, damit andere dich finden können.
@ -437,10 +442,10 @@ function _fmtDate(iso) {
<form id="${formId}"> <form id="${formId}">
<div class="form-group"> <div class="form-group">
<label class="form-label">Ort / Standort</label> <label class="form-label">Ort / Standort</label>
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
<input type="text" id="listing-ort" class="form-control" <input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München" placeholder="z.B. München"
value="${UI.escape(existing?.ort_name || '')}"> value="${_esc(existing?.ort_name || '')}">
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn" <button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln"> title="GPS-Standort ermitteln">
${UI.icon('crosshair')} ${UI.icon('crosshair')}
@ -467,7 +472,7 @@ function _fmtDate(iso) {
<div class="form-group"> <div class="form-group">
<label class="form-label">Beschreibung (optional)</label> <label class="form-label">Beschreibung (optional)</label>
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400" <textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${UI.escape(existing?.beschreibung || '')}</textarea> placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
</div> </div>
</form> </form>
`, `,
@ -573,7 +578,7 @@ function _fmtDate(iso) {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em; color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Eingehende Anfragen</h3> margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${incoming.map(r => _incomingCard(r)).join('')} ${incoming.map(r => _incomingCard(r)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -583,7 +588,7 @@ function _fmtDate(iso) {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em; color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3> margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
<div class="flex-col-gap-3"> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${outgoing.map(r => _outgoingCard(r)).join('')} ${outgoing.map(r => _outgoingCard(r)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -626,17 +631,17 @@ function _fmtDate(iso) {
function _incomingCard(r) { function _incomingCard(r) {
const isPending = r.status === 'pending'; const isPending = r.status === 'pending';
return ` return `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)} ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.from_dog_name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? UI.escape(r.from_dog_rasse) + ' · ' : ''} ${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? UI.escape(r.alter) + ' · ' : ''} ${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${UI.escape(r.from_user_name)} von ${_esc(r.from_user_name)}
</div> </div>
<div class="text-xs-muted">${_fmtDate(r.created_at)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div> </div>
${_statusBadge(r.status)} ${_statusBadge(r.status)}
</div> </div>
@ -646,11 +651,11 @@ function _fmtDate(iso) {
background:var(--c-surface-2);border-radius:var(--radius-md); background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3); padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5"> line-height:1.5">
"${UI.escape(r.nachricht)}" "${_esc(r.nachricht)}"
</div>` : ''} </div>` : ''}
${isPending ? ` ${isPending ? `
<div class="flex-gap-2"> <div style="display:flex;gap:var(--space-2)">
<button class="btn btn-primary btn-sm req-accept-btn" <button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted"> data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen ${UI.icon('check')} Annehmen
@ -671,23 +676,23 @@ function _fmtDate(iso) {
function _outgoingCard(r) { function _outgoingCard(r) {
return ` return `
<div class="card p-4"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)} ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.to_dog_name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? UI.escape(r.to_dog_rasse) + ' · ' : ''} ${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${UI.escape(r.to_user_name)} von ${_esc(r.to_user_name)}
</div> </div>
<div class="text-xs-muted">${_fmtDate(r.created_at)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div> </div>
${_statusBadge(r.status)} ${_statusBadge(r.status)}
</div> </div>
${r.nachricht ? ` ${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
"${UI.escape(r.nachricht)}" "${_esc(r.nachricht)}"
</p>` : ''} </p>` : ''}
${r.status === 'accepted' ? ` ${r.status === 'accepted' ? `

View file

@ -73,7 +73,7 @@ window.Page_poison = (() => {
<a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none"> <a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('phone')} <strong>110</strong> Polizei ${UI.icon('phone')} <strong>110</strong> Polizei
</a> </a>
<button class="btn btn-secondary" id="poison-btn-erstehilfe" class="flex-1"> <button class="btn btn-secondary" id="poison-btn-erstehilfe" style="flex:1">
${UI.icon('first-aid')} Erste Hilfe & Tiergift ${UI.icon('first-aid')} Erste Hilfe & Tiergift
</button> </button>
</div> </div>
@ -94,7 +94,8 @@ window.Page_poison = (() => {
document.getElementById('poison-btn-erstehilfe') document.getElementById('poison-btn-erstehilfe')
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' })); ?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
await _initMap(); await UI.loadLeaflet();
_initMap();
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen // Leaflet muss nach CSS-Load die Container-Größe neu berechnen
setTimeout(() => _map?.invalidateSize(), 100); setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad(); await _locateAndLoad();
@ -103,16 +104,17 @@ window.Page_poison = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// KARTE INITIALISIEREN // KARTE INITIALISIEREN
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _initMap() { function _initMap() {
const mapEl = document.getElementById('poison-map'); const mapEl = document.getElementById('poison-map');
if (!mapEl || _map) return; if (!mapEl || !window.L || _map) return;
_map = L.map('poison-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6); // Deutschland-Mitte
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
_map = await UI.map.create('poison-map', {
center: [51.1657, 10.4515], // Deutschland-Mitte
zoom: 6,
zoomControl: true,
attributionControl: false,
});
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -219,7 +221,7 @@ window.Page_poison = (() => {
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''} ${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br> <small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small> <small>📅 ${_fmtDate(r.created_at)}</small>
${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''} ${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''}
`); `);
marker.on('click', () => _openDetail(r)); marker.on('click', () => _openDetail(r));
@ -274,13 +276,13 @@ window.Page_poison = (() => {
border-left:4px solid ${typ.color}"> border-left:4px solid ${typ.color}">
<div style="display:flex;gap:var(--space-3);align-items:flex-start"> <div style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="width:40px;height:40px;flex-shrink:0;color:${typ.color};display:flex;align-items:center;justify-content:center">${UI.icon(typ.icon)}</div> <div style="width:40px;height:40px;flex-shrink:0;color:${typ.color};display:flex;align-items:center;justify-content:center">${UI.icon(typ.icon)}</div>
<div class="flex-1-min"> <div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2); <div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap"> margin-bottom:var(--space-1);flex-wrap:wrap">
<span class="badge" <span class="badge"
style="background:${typ.color};color:#fff">${typ.label}</span> style="background:${typ.color};color:#fff">${typ.label}</span>
${r.bestaetigt ${r.bestaetigt
? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>'
: ''} : ''}
<span style="margin-left:auto;color:var(--c-text-secondary); <span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap"> font-size:var(--text-sm);white-space:nowrap">
@ -293,7 +295,7 @@ window.Page_poison = (() => {
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''} ${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>` </p>`
: ''} : ''}
<div class="text-xs-secondary"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)} · Gemeldet ${_fmtDate(r.created_at)} ·
läuft ab ${_fmtDate(r.expires_at)} läuft ab ${_fmtDate(r.expires_at)}
</div> </div>
@ -334,7 +336,7 @@ window.Page_poison = (() => {
<span class="badge" style="background:${typ.color};color:#fff"> <span class="badge" style="background:${typ.color};color:#fff">
${UI.icon(typ.icon)} ${typ.label} ${UI.icon(typ.icon)} ${typ.label}
</span> </span>
${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''} ${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
</div> </div>
${r.beschreibung ${r.beschreibung
@ -351,7 +353,7 @@ window.Page_poison = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
${!r.bestaetigt && _appState.user && !isOwnEntry ${!r.bestaetigt && _appState.user && !isOwnEntry
? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>` ? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>`
: ''} : ''}
<button class="btn btn-secondary flex-1" id="detail-show-map">🗺 Auf Karte</button> <button class="btn btn-secondary flex-1" id="detail-show-map">🗺 Auf Karte</button>
${isOwnEntry || isAdmin ${isOwnEntry || isAdmin
@ -470,7 +472,7 @@ window.Page_poison = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Beschreibung Beschreibung
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<textarea class="form-control" name="beschreibung" rows="3" <textarea class="form-control" name="beschreibung" rows="3"
placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea> placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea>
@ -479,7 +481,7 @@ window.Page_poison = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Foto Foto
<span class="text-secondary">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
</label> </label>
<input class="form-control" type="file" name="photo" <input class="form-control" type="file" name="photo"
accept="image/*" capture="environment"> accept="image/*" capture="environment">
@ -591,7 +593,7 @@ window.Page_poison = (() => {
title: 'Danke für deine Meldung!', title: 'Danke für deine Meldung!',
body: ` body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)"> <div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<div class="mb-4"> <div style="margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg> <svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
</div> </div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0"> <p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">

Some files were not shown because too many files have changed in this diff Show more