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 \
&& 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)
COPY backend/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 \
/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
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 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/landing.html && rm -f backend/static/landing.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)"
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
# ----------------------------------------------------------
# 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
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:
"""True wenn User Pro-Features nutzen darf."""
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["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
@ -1763,40 +1763,19 @@ async def force_update():
<title>Ban Yaro Update</title>
<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}
p{color:#94a3b8;font-size:14px}
button{margin-top:24px;background:#C4843A;color:#fff;border:none;padding:12px 24px;
border-radius:8px;font-size:16px;cursor:pointer}</style></head>
p{color:#94a3b8;font-size:14px}</style></head>
<body>
<div> Einen Moment</div>
<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>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1');
// Cleanup IM HINTERGRUND starten (fire-and-forget) kein await,
// kein Blockieren. Selbst wenn die Promises nie resolven (iOS-Bug),
// hängen wir nicht.
try {
if (navigator.serviceWorker) {
navigator.serviceWorker.getRegistrations()
.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);
// Fire-and-forget kein await, Reload nach spätestens 1.5s
try{
navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{});
caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{});
}catch(e){}
setTimeout(()=>location.replace('/'),1500);
</script></body></html>"""
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.responses import Response
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from database import db, DB_PATH
from auth import get_current_user
@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree
class QuarterlyReportBody(BaseModel):
year: int
quarter: int
email: str = Field(..., max_length=254)
email: str
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_banned: Optional[int] = None
ban_reason: Optional[str] = Field(None, max_length=1000)
ban_reason: Optional[str] = None
is_social_media: Optional[int] = None
subscription_tier: Optional[str] = Field(None, max_length=50)
subscription_tier: Optional[str] = None
class WikiEnrichBody(BaseModel):
limit: int = 10

View file

@ -10,18 +10,18 @@ Caching: adoption_cache Tabelle, 24h TTL.
"""
import os
import math
import logging
import asyncio
import uuid
import httpx
from datetime import datetime, timedelta
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 database import db
from auth import get_current_user
from routes.push import send_push_to_user
from math_utils import haversine_km
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -31,6 +31,18 @@ router = APIRouter()
PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
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)
@ -222,7 +234,7 @@ async def adoption_nearby(
for row in rows:
d = dict(row)
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:
d["distanz_km"] = round(dist, 1)
cached_animals.append(d)
@ -238,7 +250,7 @@ async def adoption_nearby(
# ------ Statische Tierheime (immer) ------
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:
shelters.append({
"id": sid,
@ -292,7 +304,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
# ==================================================================
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["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"):
dist = haversine_km(lat, lon, d["lat"], d["lon"])
dist = _haversine(lat, lon, d["lat"], d["lon"])
d["distanz_km"] = round(dist, 1)
if dist > radius:
continue
@ -422,7 +434,7 @@ async def community_create(
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------
class _StatusBody(BaseModel):
status: str = Field(..., max_length=50)
status: str
@router.patch("/community/{listing_id}")
def community_update_status(

View file

@ -1,10 +1,10 @@
"""BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends
from database import db
from auth import get_current_user_optional as get_optional_user
from math_utils import haversine_m, bbox_deg_from_km
router = APIRouter()
@ -12,9 +12,21 @@ _RADIUS_M = 20_000 # 20 km
_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]:
"""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)
@ -48,7 +60,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user))
(lat, lon, user["id"])
)
has_poison = any(haversine_m(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_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons)
has_lost = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
return {"poison": has_poison, "lost": has_lost}

View file

@ -10,7 +10,7 @@ from typing import Optional
import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr
from database import db
from auth import (
hash_password, verify_password, create_token,
@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str):
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1, max_length=200)
password: str
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=200)
name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = Field(None, max_length=50)
password: str
name: str
ref_code: Optional[str] = None
def _gen_referral_code() -> str:
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(..., min_length=10, max_length=200)
password: str = Field(..., min_length=8, max_length=200)
token: str
password: str
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request):
@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
class UpgradeRequestBody(BaseModel):
tier: str = Field(..., max_length=50)
message: Optional[str] = Field(None, max_length=2000)
tier: str
message: Optional[str] = None
@router.post("/upgrade-request")
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.responses import FileResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
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):
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
# ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = Field(None, max_length=200)
rasse_text: Optional[str] = Field(None, max_length=200)
verein: Optional[str] = Field(None, max_length=200)
zwingername: Optional[str] = None
rasse_text: Optional[str] = None
verein: Optional[str] = None
vdh_mitglied: Optional[int] = None
stadt: Optional[str] = Field(None, max_length=200)
website: Optional[str] = Field(None, max_length=500)
beschreibung: Optional[str] = Field(None, max_length=10000)
stadt: Optional[str] = None
website: Optional[str] = None
beschreibung: Optional[str] = None
@router.put("/breeder/profile")
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)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
import os, logging, asyncio
from database import db
@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)):
# Modelle
# ------------------------------------------------------------------
class VisibilityBody(BaseModel):
visibility: str = Field(..., max_length=30)
visibility: str
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
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from database import db
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):
text: str = Field(..., min_length=1, max_length=2000)
text: str
@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()
if not text:
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:
conv = conn.execute(

View file

@ -1,8 +1,8 @@
"""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 pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, require_admin
@ -11,7 +11,6 @@ import httpx
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 timeutils import safe_client_time
from math_utils import haversine_km
logger = logging.getLogger(__name__)
@ -20,27 +19,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute
client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = Field("eintrag", max_length=50)
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
datum: Optional[str] = None # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = "eintrag"
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
gps_lat: 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
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):
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
gps_lat: 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
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")
elon = el.get("lon") or el.get("center", {}).get("lon")
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
["tourism", "historic", "leisure", "amenity", "shop"]
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]
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")
async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)):
@ -436,7 +445,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
(user["id"],)
).fetchall()
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:
results.append({"name": p["name"], "type": p["typ"] or "place",
"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 != ''"
).fetchall()
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:
results.append({"name": p["name"], "type": p["type"],
"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")
elon = el.get("lon") or el.get("center", {}).get("lon")
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
["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place")

View file

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

View file

@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
@ -16,18 +16,18 @@ logger = logging.getLogger(__name__)
# Schemas
# ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix
marke: Optional[str] = Field(None, max_length=200)
futter_typ: Optional[str] = None # trocken|nass|barf|mix
marke: Optional[str] = None
kcal_tag: Optional[int] = None
portionen: Optional[int] = None
notizen: Optional[str] = Field(None, max_length=5000)
notizen: Optional[str] = None
class KiBeratungRequest(BaseModel):
frage: str = Field(..., min_length=3, max_length=2000)
dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = Field(None, max_length=50)
frage: str
dog_name: Optional[str] = None
rasse: Optional[str] = None
alter: Optional[str] = None
gewicht: Optional[float] = None
aktiv: Optional[bool] = None
@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige
class FutterEintragCreate(BaseModel):
datum: str = Field(..., max_length=32)
uhrzeit: str = Field(..., max_length=20)
futter_name: str = Field(..., max_length=200)
futter_typ: Optional[str] = Field("trockenfutter", max_length=50)
datum: str
uhrzeit: str
futter_name: str
futter_typ: Optional[str] = "trockenfutter"
menge_g: Optional[int] = None
notiz: Optional[str] = Field(None, max_length=2000)
notiz: Optional[str] = None
class ReaktionCreate(BaseModel):
datum: str = Field(..., max_length=32)
uhrzeit: str = Field(..., max_length=20)
reaktion_typ: str = Field(..., max_length=100)
datum: str
uhrzeit: str
reaktion_typ: str
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)"""
import math
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_m
router = APIRouter()
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
# ------------------------------------------------------------------
class RsvpCreate(BaseModel):
status: str = Field('going', max_length=20) # 'going' | 'maybe'
status: str = 'going' # 'going' | 'maybe'
class EventCreate(BaseModel):
titel: str = Field(..., min_length=3, max_length=200)
datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: Optional[str] = Field(None, max_length=20)
titel: str
datum: str # YYYY-MM-DD
uhrzeit: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
ort_name: Optional[str] = Field(None, max_length=300)
typ: str = Field('sonstiges', max_length=50)
beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = Field(None, max_length=500)
ort_name: Optional[str] = None
typ: str = 'sonstiges'
beschreibung: Optional[str] = None
link: Optional[str] = None
class EventUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
titel: Optional[str] = None
datum: Optional[str] = None
uhrzeit: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
ort_name: Optional[str] = Field(None, max_length=300)
typ: Optional[str] = Field(None, max_length=50)
beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = Field(None, max_length=500)
ort_name: Optional[str] = None
typ: Optional[str] = None
beschreibung: Optional[str] = None
link: Optional[str] = None
# ------------------------------------------------------------------
@ -77,7 +86,7 @@ async def list_events(
result = [dict(r) for r in rows]
if lat is not None and lon is not None:
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

View file

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

View file

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

View file

@ -1,28 +1,37 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user
from math_utils import haversine_m
logger = logging.getLogger(__name__)
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):
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str = Field(..., max_length=20) # "17:00"
ort_name: Optional[str] = Field(None, max_length=300)
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00"
ort_name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
radius_m: int = 500
notiz: Optional[str] = Field(None, max_length=2000)
notiz: Optional[str] = None
class GassiZeitUpdate(BaseModel):
@ -74,7 +83,7 @@ async def list_gassi_zeiten(
# Distanz-Filter
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:
continue
d["distance_m"] = int(dist)

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
@ -11,9 +11,9 @@ router = APIRouter()
class TrainingRequest(BaseModel):
problem: str = Field(..., min_length=10, max_length=1000)
rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = Field(None, max_length=50)
problem: str
rasse: Optional[str] = None
alter: Optional[str] = None
@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")
if not req.problem or len(req.problem.strip()) < 10:
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"
alter = req.alter or "unbekannt"
@ -67,10 +69,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
# POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------
class TierarztRequest(BaseModel):
symptom: str = Field(..., min_length=5, max_length=1000)
symptom: str
dog_id: Optional[int] = None
dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
dog_name: Optional[str] = None
rasse: Optional[str] = None
@router.post("/tierarzt")
@ -79,6 +81,8 @@ async def ki_tierarzt(req: TierarztRequest, request: Request,
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5:
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
with db() as conn:
@ -169,10 +173,10 @@ def _log_rasse_request(user_id: int):
class BirthdayRequest(BaseModel):
dog_id: int
name: str = Field(..., max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
name: str
rasse: Optional[str] = None
alter: Optional[int] = None
mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today"
mode: str = "tomorrow" # "tomorrow" | "today"
@router.post("/geburtstag")
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):
dog_id: int
name: str = Field(..., max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
name: str
rasse: Optional[str] = None
km_total: Optional[float] = None
diary_count: Optional[int] = None
gemeinsam_tage: Optional[int] = None
last_entry_titel: Optional[str] = Field(None, max_length=200)
last_entry_titel: Optional[str] = None
@router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request,

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
@ -13,12 +13,12 @@ router = APIRouter()
# Schemas
# ------------------------------------------------------------------
class VoteRequest(BaseModel):
szenario_id: str = Field(..., max_length=100)
answer: str = Field(..., max_length=100)
szenario_id: str
answer: str
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)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from datetime import date, timedelta
@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list:
# Schemas
# ------------------------------------------------------------------
class LaeufiCreate(BaseModel):
beginn: str = Field(..., max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
beginn: str
ende: Optional[str] = None
notiz: Optional[str] = None
class LaeufiUpdate(BaseModel):
beginn: Optional[str] = Field(None, max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
beginn: Optional[str] = None
ende: Optional[str] = None
notiz: Optional[str] = None
class ProgestCreate(BaseModel):
datum: str = Field(..., max_length=32)
datum: str
wert: Optional[float] = None
einheit: str = Field("ng/ml", max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
einheit: str = "ng/ml"
labor: Optional[str] = None
notiz: Optional[str] = None
class ProgestUpdate(BaseModel):
datum: Optional[str] = Field(None, max_length=32)
datum: Optional[str] = None
wert: Optional[float] = None
einheit: Optional[str] = Field(None, max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
einheit: Optional[str] = None
labor: Optional[str] = None
notiz: Optional[str] = None
class DeckCreate(BaseModel):
deckdatum: str = Field(..., max_length=32)
deckdatum: str
laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: str = Field("natuerlich", max_length=50)
ruede_name: Optional[str] = None
deckart: str = "natuerlich"
traechtig: int = 0
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
class DeckUpdate(BaseModel):
deckdatum: Optional[str] = Field(None, max_length=32)
deckdatum: Optional[str] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: Optional[str] = Field(None, max_length=50)
ruede_name: Optional[str] = None
deckart: Optional[str] = None
traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------

View file

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

View file

@ -1,32 +1,44 @@
"""BAN YARO — Verlorener Hund Routes"""
import os, uuid
import os, uuid, math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all
from media_utils import convert_media
from math_utils import haversine_m
router = APIRouter()
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
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
beschreibung: str = Field(..., min_length=3, max_length=5000)
name: str
rasse: Optional[str] = None
beschreibung: str
lat: float
lon: float
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:
entry = dict(r)
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:
continue
entry["distanz_m"] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from database import db
@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
class MovieCreate(BaseModel):
id: str = Field(..., max_length=100)
titel: str = Field(..., min_length=1, max_length=200)
originaltitel: Optional[str] = Field(None, max_length=200)
id: str
titel: str
originaltitel: Optional[str] = None
jahr: Optional[int] = None
genre: Optional[str] = Field(None, max_length=100)
typ: str = Field("film", max_length=30)
hund_rasse: Optional[str] = Field(None, max_length=200)
stirbt_der_hund: bool = False
beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: str = Field("🐾", max_length=10)
genre: Optional[str] = None
typ: str = "film"
hund_rasse: Optional[str] = None
stirbt_der_hund: bool = False
beschreibung: Optional[str] = None
bild_emoji: str = "🐾"
imdb_rating: Optional[float] = None
streaming: Optional[str] = Field(None, max_length=500)
streaming: Optional[str] = None
class MovieUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200)
originaltitel: Optional[str] = Field(None, max_length=200)
titel: Optional[str] = None
originaltitel: Optional[str] = None
jahr: Optional[int] = None
genre: Optional[str] = Field(None, max_length=100)
typ: Optional[str] = Field(None, max_length=30)
hund_rasse: Optional[str] = Field(None, max_length=200)
genre: Optional[str] = None
typ: Optional[str] = None
hund_rasse: Optional[str] = None
stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: Optional[str] = Field(None, max_length=10)
beschreibung: Optional[str] = None
bild_emoji: Optional[str] = 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
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, Any, List
from database import db
from auth import get_current_user
@ -18,18 +18,18 @@ logger = logging.getLogger(__name__)
# Schemas
# ------------------------------------------------------------------
class NoteCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
client_time: Optional[str] = Field(None, max_length=64)
text: str
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
client_time: Optional[str] = None
class NoteUpdate(BaseModel):
text: Optional[str] = Field(None, max_length=5000)
text: Optional[str] = None
meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
location_name: Optional[str] = None
parent_label: Optional[str] = None
# ------------------------------------------------------------------

View file

@ -9,7 +9,7 @@ import httpx
import logging
from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from database import db
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:
logger.warning(f"Overpass Verbindungsfehler {url}: {exc}")
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):
stale = []
@ -273,11 +273,11 @@ async def get_pois(
# POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------
class UserPoiIn(BaseModel):
type: str = Field(..., max_length=200)
type: str
lat: float
lon: float
name: Optional[str] = Field(None, max_length=300)
notiz: Optional[str] = Field(None, max_length=2000)
name: Optional[str] = None
notiz: Optional[str] = None
ALLOWED_TYPES = {
'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
# ------------------------------------------------------------------
class ReportIn(BaseModel):
type: str = Field(..., max_length=100)
grund: str = Field(..., max_length=200)
type: str
grund: str
osm_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
# ------------------------------------------------------------------
class PoiEditCreate(BaseModel):
poi_name: str = Field(..., max_length=300)
field: str = Field('opening_hours', max_length=50)
new_value: str = Field(..., max_length=1000)
poi_name: str
field: str = 'opening_hours'
new_value: str
@router.post('/pois/{osm_id}/edit', status_code=201)

View file

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

View file

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

View file

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

View file

@ -1,40 +1,50 @@
"""BAN YARO — Hundefreundliche Orte"""
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, require_owner
from math_utils import haversine_m
from auth import get_current_user
router = APIRouter()
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
# ------------------------------------------------------------------
class PlaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
typ: str = Field(..., max_length=50)
name: str
typ: str
lat: float
lon: float
adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = Field(None, max_length=30)
adresse: Optional[str] = None
website: Optional[str] = None
telefon: Optional[str] = None
hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None
class PlaceUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200)
typ: Optional[str] = Field(None, max_length=50)
name: Optional[str] = None
typ: Optional[str] = None
lat: Optional[float]= None
lon: Optional[float]= None
adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = Field(None, max_length=30)
adresse: Optional[str] = None
website: Optional[str] = None
telefon: Optional[str] = None
hund_rein: Optional[bool] = None
leine_pflicht: 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]
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
@ -121,10 +131,11 @@ async def get_place(place_id: int):
@router.patch("/{place_id}")
async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)):
with db() as conn:
row = require_owner(
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
)
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
if not row:
raise HTTPException(404, "Ort nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
updates = data.model_dump(exclude_none=True)
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)
async def delete_place(place_id: int, user=Depends(get_current_user)):
with db() as conn:
require_owner(
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
)
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
if not row:
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,))

View file

@ -1,17 +1,30 @@
"""BAN YARO — Playdate-Matching"""
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_km
router = APIRouter()
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]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag:
@ -40,18 +53,18 @@ class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = Field(None, max_length=300)
ort_name: Optional[str] = None
radius_km: int = 10
beschreibung: Optional[str] = Field(None, max_length=2000)
beschreibung: Optional[str] = None
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = Field(None, max_length=2000)
nachricht: Optional[str] = None
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 = []
for r in rows:
dist = haversine_km(lat, lon, r["lat"], r["lon"])
dist = _haversine(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],

View file

@ -1,33 +1,45 @@
"""BAN YARO — Giftköder-Alarm Routes"""
import os, uuid
import os, uuid, math
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_nearby
from media_utils import convert_media
from ratelimit import check as rl_check
from math_utils import haversine_m
router = APIRouter()
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
# ------------------------------------------------------------------
class PoisonCreate(BaseModel):
lat: float
lon: float
beschreibung: Optional[str] = Field(None, max_length=2000)
typ: str = Field("unbekannt", max_length=50)
beschreibung: Optional[str] = None
typ: str = "unbekannt"
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 = []
for r in rows:
entry = dict(r)
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist <= radius:
entry["distanz_m"] = round(dist)
results.append(entry)

View file

@ -7,7 +7,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from auth import get_current_user
from database import db
@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel):
real_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=300)
wohnort: Optional[str] = Field(None, max_length=60)
erfahrung: Optional[str] = Field(None, max_length=30)
social_link: Optional[str] = Field(None, max_length=120)
profil_sichtbarkeit: Optional[str] = Field(None, max_length=30)
real_name: Optional[str] = None
bio: Optional[str] = None
wohnort: Optional[str] = None
erfahrung: Optional[str] = None
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = Field(None, max_length=20)
billing_address: Optional[str] = Field(None, max_length=500)
geburtstag: Optional[str] = Field(None, max_length=10)
preferred_theme: Optional[str] = None
billing_address: Optional[str] = None
geburtstag: Optional[str] = None
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.")
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.")
# 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 not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
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 logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from pywebpush import webpush, WebPushException
@ -33,7 +33,7 @@ async def get_vapid_key():
# POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------
class PushSubscription(BaseModel):
endpoint: str = Field(..., max_length=2000)
endpoint: str
keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None

View file

@ -1,7 +1,7 @@
"""BAN YARO — Bewertungssystem (Ratings)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
@ -23,10 +23,10 @@ TABLE_MAP = {
# Schemas
# ------------------------------------------------------------------
class RatingCreate(BaseModel):
target_type: str = Field(..., max_length=50)
target_type: str
target_id: 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
# ------------------------------------------------------------------
async def fetch_rasff_recalls() -> list[dict]:
"""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.
"""
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
resp.raise_for_status()
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:
logger.error(f"RASFF API-Fehler: {e}")
return []

View file

@ -1,11 +1,11 @@
"""BAN YARO — Gassi-Routen"""
import datetime as _dt
import json, os, uuid
import json, math, os, uuid
import httpx
import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from database import db
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 media_utils import convert_media
from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
router = APIRouter()
@ -28,6 +27,16 @@ def _check_speed(distanz_km, dauer_min) -> bool:
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
# ------------------------------------------------------------------
@ -37,29 +46,29 @@ class GPSPoint(BaseModel):
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
name: str
beschreibung: Optional[str] = None
gps_track: List[GPSPoint]
distanz_km: Optional[float] = None
dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix
schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = Field(None, max_length=64)
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
schwierigkeit: Optional[str] = Field(None, max_length=30)
untergrund: Optional[str] = Field(None, max_length=50)
name: Optional[str] = None
beschreibung: Optional[str] = None
schwierigkeit: Optional[str] = None
untergrund: Optional[str] = None
schatten: Optional[bool] = None
leine_empfohlen: 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):
dog_ids: List[int]
@ -128,7 +137,7 @@ async def list_routes(
if lat is not None and lon is not None:
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
@ -420,7 +429,10 @@ async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_us
new_km = 0.0
for i in range(1, len(new_track)):
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)
# Dauer proportional schätzen (Original-Pace)
@ -553,7 +565,7 @@ async def add_route_photo(
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------
class RouteFeedback(BaseModel):
text: str = Field(..., min_length=5, max_length=2000)
text: str
@router.post("/{route_id}/feedback", status_code=201)
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)"""
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_km
router = APIRouter()
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
# ------------------------------------------------------------------
class ServiceCreate(BaseModel):
type: str = Field(..., max_length=30)
beschreibung: Optional[str] = Field(None, max_length=5000)
type: str
beschreibung: Optional[str] = None
preis_pro_tag: Optional[float] = None
lat: Optional[float] = None
lon: Optional[float] = None
@ -50,7 +60,7 @@ async def list_services(
for r in rows:
d = dict(r)
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:
continue
d['distanz_km'] = round(dist, 1)

View file

@ -2,7 +2,7 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from database import db
from auth import get_current_user
@ -14,7 +14,7 @@ share_router = APIRouter()
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"""
import json
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user
from math_utils import haversine_m
router = APIRouter()
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
# ------------------------------------------------------------------
class SitterCreate(BaseModel):
beschreibung: Optional[str] = Field(None, max_length=5000)
beschreibung: Optional[str] = None
preis_pro_tag: float = 0
max_hunde: int = 1
lat: Optional[float] = None
@ -26,7 +35,7 @@ class SitterCreate(BaseModel):
services: List[str] = []
class SitterUpdate(BaseModel):
beschreibung: Optional[str] = Field(None, max_length=5000)
beschreibung: Optional[str] = None
preis_pro_tag: Optional[float] = None
max_hunde: Optional[int] = None
lat: Optional[float] = None
@ -38,12 +47,12 @@ class SitterUpdate(BaseModel):
class RequestCreate(BaseModel):
sitter_id: int
dog_ids: List[int] = []
von: str = Field(..., max_length=32) # YYYY-MM-DD
bis: str = Field(..., max_length=32)
nachricht: Optional[str] = Field(None, max_length=2000)
von: str # YYYY-MM-DD
bis: str
nachricht: Optional[str] = None
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']:
continue
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:
continue
d['distanz_m'] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel
from database import db
from auth import get_current_user
@ -11,7 +11,7 @@ router = APIRouter()
class AccessCreate(BaseModel):
dog_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)

View file

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

View file

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

View file

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

View file

@ -1,43 +1,55 @@
"""BAN YARO — Gassi-Treffen"""
import os, uuid
import math, os, uuid
import httpx
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_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")
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
# ------------------------------------------------------------------
class WalkCreate(BaseModel):
titel: str = Field(..., min_length=1, max_length=200)
datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: str = Field(..., max_length=20) # HH:MM
titel: str
datum: str # YYYY-MM-DD
uhrzeit: str # HH:MM
lat: float
lon: float
ort_name: Optional[str] = Field(None, max_length=300)
ort_name: Optional[str] = None
max_teilnehmer: int = 10
beschreibung: Optional[str] = Field(None, max_length=5000)
beschreibung: Optional[str] = None
class WalkUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
titel: Optional[str] = None
datum: Optional[str] = None
uhrzeit: Optional[str] = None
lat: 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
beschreibung: Optional[str] = Field(None, max_length=5000)
beschreibung: Optional[str] = None
class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
@ -46,7 +58,7 @@ class InviteRequest(BaseModel):
friend_id: int
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
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
@ -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",
).fetchall()
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:
results.append({"name": p["name"], "type": p["typ"] or "place",
"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 != ''"
).fetchall()
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:
results.append({"name": p["name"], "type": p["type"],
"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")
if elat is None or elon is None:
continue
km = haversine_km(lat, lon, elat, elon)
km = _haversine_km(lat, lon, elat, elon)
if km <= 1:
results.append({"name": name, "type": "osm",
"lat": elat, "lon": elon,

View file

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

View file

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

View file

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

View file

@ -46,14 +46,6 @@ def start():
misfire_grace_time=3600,
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(
_job_weather_alert,
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}")
with db() as conn:
# diary hat keinen user_id — User kommt über dogs.user_id
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
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
FROM diary d
JOIN dogs ON dogs.id = d.dog_id
WHERE strftime('%m-%d', d.datum) = ?
AND d.datum < date('now')
AND d.titel IS NOT NULL
@ -2241,16 +2231,3 @@ async def _job_error_digest():
except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {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);
}
/* ----- .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
------------------------------------------------------------ */
@ -8944,44 +8905,3 @@ svg.empty-state-icon {
.offline-status-row .osr-text { flex: 1; min-width: 0; }
.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; }
/* ============================================================
.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 */
--c-text: #2A1F14;
--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;
/* Funktionsfarben */
@ -179,7 +179,7 @@
--c-text: #F0EAE0;
--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;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);

View file

@ -86,8 +86,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
color: var(--c-text-secondary);
cursor: pointer;
@ -99,8 +99,8 @@
/* Hamburger-Button (nur Mobile) */
.header-menu-btn {
width: 44px;
height: 44px;
width: 40px;
height: 40px;
display: flex;
align-items: 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>
<!-- 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 -->
<link rel="stylesheet" href="/css/design-system.css?v=1120">
<link rel="stylesheet" href="/css/layout.css?v=1120">
<link rel="stylesheet" href="/css/components.css?v=1120">
<link rel="stylesheet" href="/css/utilities.css?v=1120">
<link rel="stylesheet" href="/css/lists.css?v=1120">
<link rel="stylesheet" href="/css/design-system.css?v=1099">
<link rel="stylesheet" href="/css/layout.css?v=1099">
<link rel="stylesheet" href="/css/components.css?v=1099">
</head>
<body>
@ -101,8 +111,7 @@
<div id="offline-banner" aria-live="polite"
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;
padding:calc(env(safe-area-inset-top, 0px) + 7px) 16px 7px;
align-items:center;justify-content:center;gap:8px;
padding:7px 16px;align-items:center;justify-content:center;gap:8px;
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">
<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"
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;
padding:calc(env(safe-area-inset-top, 0px) + 8px) 16px 8px;
align-items:center;justify-content:center;gap:10px;
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
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">
<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 id="header-actions"></div>
<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;
display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative">
@ -617,11 +625,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1120"></script>
<script src="/js/ui.js?v=1120"></script>
<script src="/js/app.js?v=1120"></script>
<script src="/js/worlds.js?v=1120"></script>
<script src="/js/offline-indicator.js?v=1120"></script>
<script src="/js/api.js?v=1099"></script>
<script src="/js/ui.js?v=1099"></script>
<script src="/js/app.js?v=1099"></script>
<script src="/js/worlds.js?v=1099"></script>
<script src="/js/offline-indicator.js?v=1099"></script>
<!-- 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>
<!-- 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) -->
<script src="/js/boot.js?v=1120"></script>
<!-- Service Worker -->
<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>

View file

@ -3,7 +3,7 @@
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
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;
@ -129,23 +129,16 @@ const App = (() => {
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
// 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) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
let lastForce = 0;
try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {}
const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000;
if (!modalOpen && !cooldownActive) {
if (!modalOpen) {
window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId);
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {}
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
location.href = '/force-update';
return;
}
// Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen
// Modal offen → beim nächsten Seitenwechsel versuchen
}
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 });
}
// ----------------------------------------------------------
// 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
function _prefetchPages() {
['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();
});
}
_checkStorageQuota(); // beim Init prüfen
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
setInterval(() => { _prefetchData(); refresh(); }, 60_000);
}
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"
placeholder="Rasse filtern…"
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"
style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort
@ -270,7 +270,7 @@ window.Page_adoption = (() => {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<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">
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
in deiner Umgebung zu finden.
@ -306,7 +306,7 @@ window.Page_adoption = (() => {
if (!animals.length) {
content.innerHTML = `
<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>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste"
@ -339,7 +339,7 @@ window.Page_adoption = (() => {
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
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
</a>
</div>
@ -355,7 +355,7 @@ window.Page_adoption = (() => {
function _animalCard(a) {
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"
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>';
@ -366,7 +366,7 @@ window.Page_adoption = (() => {
const tierheim = a.tierheim || '';
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;
background:var(--c-surface-2);cursor:pointer;
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="font-weight:600;font-size:var(--text-sm);
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(a.name)}
${_esc(a.name)}
</div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(rasseTxt)}
${_esc(rasseTxt)}
</div>` : ''}
<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);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${UI.escape(alterTxt)}
${_esc(alterTxt)}
</span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -396,12 +396,12 @@ window.Page_adoption = (() => {
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${UI.escape(distTxt)}
${_esc(distTxt)}
</span>` : ''}
</div>
${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)}">
${UI.icon('house-line')} ${UI.escape(tierheim)}
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
${UI.icon('house-line')} ${_esc(tierheim)}
</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)">
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
</p>
<div class="flex-col-gap-2">
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${shelters.map(s => _shelterRow(s)).join('')}
</div>
<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">
<a href="https://www.tierheimhelden.de"
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
</a>
<a href="https://www.tierschutz.com/tierheimsuche/"
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
</a>
</div>
@ -459,7 +459,7 @@ window.Page_adoption = (() => {
function _shelterRow(s) {
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);
padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit;
@ -473,13 +473,13 @@ window.Page_adoption = (() => {
font-size:1.2rem">
🏠
</div>
<div class="flex-1-min">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(s.name)}
${_esc(s.name)}
</div>
<div class="text-xs-secondary">
${UI.escape(s.plz)} ${UI.escape(s.stadt)}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_esc(s.plz)} ${_esc(s.stadt)}
</div>
</div>
<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 = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<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">
Hier können Halter Hunde privat zur Weitervermittlung anbieten
zum Beispiel bei Umzug, Krankheit oder Allergie.
@ -530,7 +530,7 @@ window.Page_adoption = (() => {
${UI.icon('plus')} Hund zur Vermittlung anbieten
</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.
</p>
`}
@ -556,8 +556,8 @@ window.Page_adoption = (() => {
${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)">
<h4 class="mb-3">Meine Inserate</h4>
<div class="flex-col-gap-2">
<h4 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${_myListings.map(l => _myListingRow(l)).join('')}
</div>
</div>
@ -610,7 +610,7 @@ window.Page_adoption = (() => {
function _communityCard(l) {
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"
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>';
@ -635,11 +635,11 @@ window.Page_adoption = (() => {
const interestBtn = l.user_interested
? `<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
</button>`
: `<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' : ''}>
Interesse bekunden
</button>`;
@ -657,7 +657,7 @@ window.Page_adoption = (() => {
display:flex;align-items:center;justify-content:center">
<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">
${UI.escape(statusLabel)}
${_esc(statusLabel)}
</span>
</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="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.name)}
${_esc(l.name)}
</div>
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.rasse)}
${_esc(l.rasse)}
</div>` : ''}
<!-- Badges -->
<div style="display:flex;gap:4px;flex-wrap:wrap">
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${UI.escape(alterLabel)}
${_esc(alterLabel)}
</span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -684,14 +684,14 @@ window.Page_adoption = (() => {
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${UI.escape(distTxt)}
${_esc(distTxt)}
</span>` : ''}
</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);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical">
${UI.escape(l.beschreibung)}
${_esc(l.beschreibung)}
</div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${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);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
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);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(l.name)}
${_esc(l.name)}
</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' : ''}
</div>
</div>
<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 => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')}
</select>
<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
</button>
</div>
@ -764,7 +764,7 @@ window.Page_adoption = (() => {
// Interesse bekunden — Modal mit optionaler Nachricht
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)">
Du kannst optional eine Nachricht an den Anbieter schicken.
</p>
@ -816,9 +816,9 @@ window.Page_adoption = (() => {
}
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">
<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">
</div>
<div class="form-group">
@ -849,7 +849,7 @@ window.Page_adoption = (() => {
<div class="form-group">
<label class="form-label">PLZ</label>
<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 class="form-group">
<label class="form-label">Ort</label>
@ -857,7 +857,7 @@ window.Page_adoption = (() => {
</div>
</div>
<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"
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>
@ -876,7 +876,7 @@ window.Page_adoption = (() => {
const footer = `
<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
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
@ -941,6 +941,15 @@ window.Page_adoption = (() => {
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
// ----------------------------------------------------------

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 _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ----------------------------------------------------------
// INIT
@ -49,7 +51,7 @@ window.Page_breeder = (() => {
} catch (e) {
document.getElementById('breeder-profile-body').innerHTML =
`<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>`;
}
}
@ -73,22 +75,22 @@ window.Page_breeder = (() => {
padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<div style="max-width:640px;margin:0 auto">
<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">
${UI.icon('seal-check')} Verifizierter Züchter
</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">
${UI.escape(p.zwingername)}
${_esc(p.zwingername)}
</h1>
<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.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${UI.escape(p.stadt)}</span>` : ''}
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${UI.escape(seit)}</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 ${_esc(seit)}</span>` : ''}
</div>
</div>
${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;
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'">`
@ -115,7 +117,7 @@ window.Page_breeder = (() => {
Anmelden um zu schreiben
</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);
border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:600;font-size:var(--text-sm);text-decoration:none;
@ -132,7 +134,7 @@ window.Page_breeder = (() => {
${p.beschreibung ? `
<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)">
<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>` : ''}
<!-- Zuchthunde -->
@ -155,7 +157,7 @@ window.Page_breeder = (() => {
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('baby')} Aktuelle Würfe
</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('')}
</div>
</div>` : ''}
@ -190,8 +192,8 @@ window.Page_breeder = (() => {
${p.website ? `
<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>
<dd style="margin:0"><a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${UI.escape(p.website)}</a></dd>
<dd style="margin:0"><a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd>
</div>` : ''}
${seit ? _dl('Züchter seit', seit) : ''}
</dl>
@ -199,7 +201,7 @@ window.Page_breeder = (() => {
<!-- Fotos / Gallery -->
${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;
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('images')} Galerie
@ -207,11 +209,11 @@ window.Page_breeder = (() => {
</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)">
${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;
border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};
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'}"
style="width:100%;height:100%;object-fit:cover;display:block"
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>` : ''}
${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
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('')}
</div>
</div>` : ''}
<div id="breeder-photos-section" class="hidden"></div>
<div id="breeder-photos-section" style="display:none"></div>
</div>`;
@ -249,18 +251,18 @@ window.Page_breeder = (() => {
const augeTest = h.health_tests?.find(t => t.test_typ === 'augen');
const testPills = [
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${UI.escape(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${UI.escape(edTest.ergebnis)}</span>` : '',
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${_esc(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '',
augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '',
].filter(Boolean).join('');
const titlePills = (h.titel || []).map(t =>
`<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('');
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
</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);
padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span class="text-primary">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${UI.escape(h.rufname)}"</span>` : ''}
<span style="color:var(--c-primary)">${gIcon}</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)">"${_esc(h.rufname)}"</span>` : ''}
${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''}
</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>` : ''}
${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''}
${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);
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)">
<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;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
</div>
<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.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>
${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>`;
}
@ -338,12 +340,12 @@ window.Page_breeder = (() => {
return `
<div>
<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)">
${stats.map(r => `
<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 class="text-muted">${r.cnt}×</span>
<span style="font-weight:700">${_esc(r.ergebnis || '—')}</span>
<span style="color:var(--c-text-muted)">${r.cnt}×</span>
<span style="background:var(--c-border);border-radius:999px;height:6px;
width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span>
</div>`).join('')}
@ -357,8 +359,8 @@ window.Page_breeder = (() => {
function _dl(label, value) {
if (!value) return '';
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>
<dd style="margin:0;font-size:var(--text-sm)">${UI.escape(String(value))}</dd>
<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)">${_esc(String(value))}</dd>
</div>`;
}
@ -375,16 +377,16 @@ window.Page_breeder = (() => {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos?.length) return;
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">
${UI.icon('images')} Fotos
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${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;
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"
onerror="this.parentElement.style.display='none'">
</a>`).join('')}

View file

@ -122,7 +122,7 @@ window.Page_chat = (() => {
el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase();
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>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0
@ -138,7 +138,7 @@ window.Page_chat = (() => {
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
</div>
<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>
<div class="chat-conv-meta">
@ -178,7 +178,7 @@ window.Page_chat = (() => {
</button>`}
<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>
<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>
<span class="chat-thread-partner" id="chat-partner-name"></span>
</div>
@ -188,7 +188,7 @@ window.Page_chat = (() => {
</div>
</div>
<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)">
<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>
@ -332,10 +332,10 @@ window.Page_chat = (() => {
}
if (m.text) {
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
UI.escape(m.text) +
_esc(m.text) +
(m.media_url ? `</div>` : '');
}
if (!bubbleContent) bubbleContent = UI.escape(m.text);
if (!bubbleContent) bubbleContent = _esc(m.text);
html += `
<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' });
}
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
// ----------------------------------------------------------

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>
</button>
</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-list"></div>
</div>
@ -295,7 +295,7 @@ window.Page_diary = (() => {
`;
card.innerHTML = `
<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);
color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)">
@ -963,7 +963,7 @@ window.Page_diary = (() => {
// Hunde-Chips (bei mehreren Hunden)
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 => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
@ -1279,7 +1279,7 @@ window.Page_diary = (() => {
value="${entry?.datum || today}" required>
</div>
<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"
value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div>
@ -1293,10 +1293,10 @@ window.Page_diary = (() => {
<div id="diary-existing-media"></div>
<!-- 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 -->
<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) -->
<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>
</div>
<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) -->
<div style="position:relative">
@ -1318,7 +1318,7 @@ window.Page_diary = (() => {
</div>
<!-- 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 class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
@ -1341,7 +1341,7 @@ window.Page_diary = (() => {
${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)">
<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"
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>
@ -1353,10 +1353,10 @@ window.Page_diary = (() => {
const footer = `
<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'}
</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>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div>
@ -1843,32 +1843,32 @@ window.Page_diary = (() => {
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</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">
<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">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div>
<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>
</label>
<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">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div>
<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>
</label>
</div>
<div class="mt-4">
<div style="margin-top:var(--space-4)">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer">
@ -1917,7 +1917,7 @@ window.Page_diary = (() => {
: await API.importData.csv(dogId, file);
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>`
: '';
@ -1925,7 +1925,7 @@ window.Page_diary = (() => {
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)">
<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}
</div>`;
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">
${dog.foto_url
? `<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}%)">
</div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
@ -95,28 +95,28 @@ window.Page_dog_profile = (() => {
<!-- Name + Rasse -->
<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
? `<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>`}
<!-- 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 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
margin-bottom:var(--space-5);text-align:left">
${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 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)}
</div>
</div>
` : ''}
${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 style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
@ -130,19 +130,19 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
${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 style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</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);
margin-bottom:2px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder
</div>
${dog.chip_nr
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${UI.escape(dog.chip_nr)}</div>`
: `<div class="text-xs-muted">nicht eingetragen
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${_esc(dog.chip_nr)}</div>`
: `<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"
style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button>
</div>`
@ -153,7 +153,7 @@ window.Page_dog_profile = (() => {
${dog.bio ? `
<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">
"${UI.escape(dog.bio)}"
"${_esc(dog.bio)}"
</p>
</div>
` : ''}
@ -230,12 +230,12 @@ window.Page_dog_profile = (() => {
<div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<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.
Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen.
</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>
` : ''}
`;
@ -335,12 +335,12 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
</svg>
${UI.escape(skill.exercise_name)}
${_esc(skill.exercise_name)}
</span>`;
};
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);
color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
@ -360,7 +360,7 @@ window.Page_dog_profile = (() => {
</div>` : '';
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)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
@ -409,11 +409,11 @@ window.Page_dog_profile = (() => {
: '';
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)">
<span style="font-size:1.1em">🛁</span>
<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>
</div>
@ -426,24 +426,24 @@ window.Page_dog_profile = (() => {
${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'}
</div>
<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 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)">
🔄 ${UI.escape(t.haeufigkeit)}</div>` : ''}
🔄 ${_esc(t.haeufigkeit)}</div>` : ''}
${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
🛒 ${UI.escape(t.materialien)}</div>` : ''}
🛒 ${_esc(t.materialien)}</div>` : ''}
${t.schritte?.length ? `
<details style="margin-top:8px">
<summary style="font-size:12px;cursor:pointer;color:var(--c-primary);
font-weight:600">Anleitung anzeigen</summary>
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;
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>
${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>` : ''}
</div>` : ''}
@ -457,29 +457,29 @@ window.Page_dog_profile = (() => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
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);
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 => `
<details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px">
<summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer;
list-style:none;display:flex;justify-content:space-between;
align-items:center">
${UI.escape(tip.titel)}
${_esc(tip.titel)}
${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''}
</summary>
<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);
margin-top:4px">🔄 ${UI.escape(tip.haeufigkeit)}</div>` : ''}
margin-top:4px">🔄 ${_esc(tip.haeufigkeit)}</div>` : ''}
${tip.schritte?.length ? `
<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>` : ''}
${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('')}
</div>`;
}).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
// ----------------------------------------------------------
@ -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)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)">
<strong>${UI.escape(s.sitter_name)}</strong>
<span class="text-muted"> · bis ${UI.escape(s.valid_until)}</span>
<strong>${_esc(s.sitter_name)}</strong>
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
</div>
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
style="color:var(--c-danger);padding:0">
@ -532,7 +538,7 @@ window.Page_dog_profile = (() => {
}
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>';
const today = new Date().toISOString().slice(0, 10);
@ -541,26 +547,26 @@ window.Page_dog_profile = (() => {
wrap.innerHTML = `
${activeHtml}
${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);
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);
align-items:end">
<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">
<option value="">Freund wählen</option>
${friendOptions}
</select>
</div>
<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"
value="${defaultUntil}" min="${today}">
</div>
</div>
<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
</button>
</div>
@ -611,11 +617,11 @@ window.Page_dog_profile = (() => {
<div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label>
<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>`,
footer: `
<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>
</div>`,
});
@ -660,20 +666,20 @@ window.Page_dog_profile = (() => {
<div class="photo-editor-controls">
<label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
class="w-full">
style="width:100%">
</div>
` : ''}
<label class="btn btn-secondary" style="cursor:pointer">
${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>
</div>
`;
const footer = `
<div class="w3-btn-stack">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''}
<div class="flex-gap-2">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
<div style="display:flex;gap:var(--space-2)">
${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>
</div>
@ -837,15 +843,15 @@ window.Page_dog_profile = (() => {
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${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">`
: `<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;
flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${UI.escape(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${UI.escape(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${UI.escape(wohnort)}</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">${_esc(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${_esc(wohnort)}</div>` : ''}
</div>
</div>
@ -854,13 +860,13 @@ window.Page_dog_profile = (() => {
<!-- Owner + QR -->
<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>
<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>
<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"
alt="QR-Code">
<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({
title: 'Visitenkarte',
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">
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>
`,
footer: `
@ -929,7 +935,7 @@ window.Page_dog_profile = (() => {
async function _showShareModal(dog) {
UI.modal.open({
title: `${UI.escape(dog.name)} teilen`,
title: `${_esc(dog.name)} teilen`,
body: `
<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.
@ -946,7 +952,7 @@ window.Page_dog_profile = (() => {
<label class="form-label">Einladungslink</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<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">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
@ -955,7 +961,7 @@ window.Page_dog_profile = (() => {
Dieser Link kann einmalig angenommen werden.
</p>
</div>
<div id="share-list-wrap" class="mt-4"></div>`,
<div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</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>
<div style="flex:1;font-size:var(--text-sm)">
${s.shared_with_name
? `<strong>${UI.escape(s.shared_with_name)}</strong> · ${s.role}`
: `<em class="text-muted">Ausstehend</em> · ${s.role}`}
? `<strong>${_esc(s.shared_with_name)}</strong> · ${s.role}`
: `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`}
</div>
<button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}"
style="color:var(--c-danger);padding:0">
@ -1050,7 +1056,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true),
footer: `
<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>
</div>
`,
@ -1067,8 +1073,8 @@ window.Page_dog_profile = (() => {
body: _formHTML(dog, true),
footer: `
<div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary w-full">Speichern</button>
<div class="flex-gap-2">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
<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" id="dp-gedenken-btn"
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">
<label class="form-label">Name *</label>
<input class="form-control" type="text" name="name"
value="${UI.escape(dog?.name || '')}"
value="${_esc(dog?.name || '')}"
placeholder="z. B. Ban Yaro" required>
</div>
<div class="form-group">
<label class="form-label">
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.')}
</label>
<input class="form-control" type="text" name="rasse"
id="dp-rasse-input"
value="${UI.escape(dog?.rasse || '')}"
value="${_esc(dog?.rasse || '')}"
list="dp-rasse-list"
autocomplete="off"
placeholder="z. B. Mischling, Golden Retriever…">
@ -1120,7 +1126,7 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Geburtstag</label>
<input class="form-control" type="date" name="geburtstag"
@ -1136,7 +1142,7 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Gewicht (kg)</label>
<input class="form-control" type="number" name="gewicht_kg"
@ -1154,14 +1160,14 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">
Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label>
<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>
@ -1169,7 +1175,7 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">
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.')}
</label>
<select class="form-control" name="fell_typ">
@ -1186,10 +1192,10 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">
Bio / Steckbrief
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<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 class="form-group">
@ -1210,7 +1216,7 @@ window.Page_dog_profile = (() => {
display:${dog?.foto_url ? 'block' : 'none'}">
<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
<input type="file" name="foto" accept="image/*" class="hidden"
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
@ -1219,7 +1225,7 @@ window.Page_dog_profile = (() => {
Rasse erkennen
</button>
<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 style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
@ -1467,11 +1473,11 @@ window.Page_dog_profile = (() => {
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<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>
Bitte lade ein deutlicheres Foto hoch.
</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>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
@ -1484,24 +1490,24 @@ window.Page_dog_profile = (() => {
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<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>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></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">
${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
</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
</button>`}
${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
</button>` : ''}
</div>
@ -1515,7 +1521,7 @@ window.Page_dog_profile = (() => {
<div style="padding-bottom:var(--space-2)">
${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);
color:var(--c-text-secondary)"> ${UI.escape(data.hinweis)}</div>` : ''}
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
@ -1576,13 +1582,18 @@ window.Page_dog_profile = (() => {
: `${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
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${UI.escape(dog.name)}`,
title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<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">
@ -1625,7 +1636,7 @@ window.Page_dog_profile = (() => {
try {
data = await API.get(`/passport/${dog.id}`);
} 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;
}
@ -1655,25 +1666,25 @@ window.Page_dog_profile = (() => {
Bearbeiten
</button>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<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">
${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 class="text-xs-secondary">Allergien</div>
<div id="pp-meta-allergien" class="text-sm">
${UI.escape(meta.allergien) || '<span class="text-muted">keine</span>'}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
</div>
</div>
</div>
${meta.besonderheiten ? `
<div class="mt-3">
<div class="text-xs-secondary">Besonderheiten</div>
<div id="pp-meta-besonderheiten" class="text-sm">
${UI.escape(meta.besonderheiten)}
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${_esc(meta.besonderheiten)}
</div>
</div>` : ''}
</div>
@ -1697,13 +1708,13 @@ window.Page_dog_profile = (() => {
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
class="pp-data-row">
<div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(v.krankheit)}</div>
<div style="flex:1">
<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">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${UI.escape(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${UI.escape(v.charge_nr)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
@ -1716,7 +1727,7 @@ window.Page_dog_profile = (() => {
</div>
<!-- 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;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
@ -1734,13 +1745,13 @@ window.Page_dog_profile = (() => {
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
class="pp-data-row">
<div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(m.name)}</div>
<div style="flex:1">
<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">
${m.dosierung ? `${UI.escape(m.dosierung)} · ` : ''}
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${UI.escape(m.notiz)}` : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div>
</div>
<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">
<label class="form-label">Blutgruppe</label>
<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 class="form-group">
<label class="form-label">Allergien</label>
<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 class="form-group">
<label class="form-label">Besonderheiten</label>
<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>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
@ -1860,7 +1871,7 @@ window.Page_dog_profile = (() => {
<option value="DHPP (Kombi)">
</datalist>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<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"
placeholder="z. B. 1× täglich, 5 mg">
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}">
</div>
<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">
</div>
</div>
@ -1990,7 +2001,7 @@ window.Page_dog_profile = (() => {
</p>
<div style="display:flex;gap:var(--space-2);align-items:center">
<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">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
@ -2026,7 +2037,7 @@ window.Page_dog_profile = (() => {
return;
}
const name = UI.escape(data.dog_name);
const name = _esc(data.dog_name);
const km = data.gesamt_km || 0;
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>
${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.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${UI.escape(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${UI.escape(aktivitaet)}</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: ${_esc(aktivitaet)}</div>` : ''}
`),
_card(`
<div style="font-size:2rem">🌡</div>
@ -2115,8 +2126,8 @@ window.Page_dog_profile = (() => {
</div>
<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>
<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-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-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:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</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) {
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)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
@ -2303,7 +2314,7 @@ window.Page_dog_profile = (() => {
data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) {
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;
}
@ -2340,14 +2351,14 @@ window.Page_dog_profile = (() => {
for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) {
html += `<div class="tl-year">${UI.escape(year)}</div>`;
html += `<div class="tl-year">${_esc(year)}</div>`;
lastYear = year;
}
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
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 === 'route') label = `🎉 Erste Route — ${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>
<div class="tl-card">
${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">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg>
${UI.escape(kat.label)}
${_esc(kat.label)}
</span>
<span class="tl-date">${_fmtDate(ev.datum)}</span>
</div>
@ -2440,8 +2451,8 @@ window.Page_dog_profile = (() => {
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${UI.escape(hauptRasse)}-Halter in der App`
: `${data.count} andere ${UI.escape(hauptRasse)}-Halter in der App`;
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
@ -2487,7 +2498,7 @@ window.Page_dog_profile = (() => {
</form>`,
footer: `
<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>
Gedenkseite erstellen
</button>
@ -2539,22 +2550,22 @@ window.Page_dog_profile = (() => {
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div class="text-xs-secondary">km zusammen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
</div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div class="text-xs-secondary">Tagebucheinträge</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
</div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
<div class="text-xs-secondary">Fotos</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
</div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div class="text-xs-secondary">gemeinsame Tage</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
</div>` : ''}
</div>`;
@ -2585,8 +2596,8 @@ window.Page_dog_profile = (() => {
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div>
</div>
<div id="gedenk-ki-wrap" class="mt-4">
<button id="gedenk-ki-btn" class="btn btn-secondary w-full">
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen
</button>

View file

@ -21,6 +21,14 @@ window.Page_ernaehrung = (() => {
// ------------------------------------------------------------------
// 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
@ -148,17 +156,17 @@ window.Page_ernaehrung = (() => {
<div class="ern-field">
<label> Gewicht (kg)</label>
<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 class="ern-field">
<label>🎂 Alter (Jahre)</label>
<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>
<!-- 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-pill-group">
<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">
<label class="by-label">Marke / Produkt</label>
<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 class="by-form-group" style="margin:0">
<label class="by-label">Portionen pro Tag</label>
@ -211,7 +219,7 @@ window.Page_ernaehrung = (() => {
<div class="by-form-group" style="margin:0">
<label class="by-label">Notizen</label>
<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>
<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>
@ -280,13 +288,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<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)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${trocken} g / Tag
</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
</div>
</div>
@ -294,13 +302,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<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)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${nass} g / Tag
</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
</div>
</div>
@ -308,13 +316,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<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)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${barf} g / Tag
</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
</div>
</div>
@ -474,8 +482,8 @@ window.Page_ernaehrung = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:1.4rem">${item.emoji}</span>
<div>
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${UI.escape(item.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-danger)">${UI.escape(item.grund)}</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)">${_esc(item.grund)}</div>
</div>
</div>
</div>
@ -503,7 +511,7 @@ window.Page_ernaehrung = (() => {
border:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
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.
</div>
@ -516,8 +524,8 @@ window.Page_ernaehrung = (() => {
'Welche Leckerlis sind gesund?',
].map(q => `
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
data-q="${UI.escape(q)}"
class="text-xs">${UI.escape(q)}</button>
data-q="${_esc(q)}"
style="font-size:var(--text-xs)">${_esc(q)}</button>
`).join('')}
</div>
@ -525,7 +533,7 @@ window.Page_ernaehrung = (() => {
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
<!-- Eingabe -->
<div class="flex-gap-2">
<div style="display:flex;gap:var(--space-2)">
<textarea id="ern-ki-frage" class="by-input" rows="2"
placeholder="Deine Frage zur Ernährung..."
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="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)">
${UI.escape(frage)}
${_esc(frage)}
</div>
</div>
`);
@ -578,7 +586,7 @@ window.Page_ernaehrung = (() => {
// KI-Antwort Placeholder
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
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);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
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/g, '<br>');
@ -738,7 +746,7 @@ window.Page_ernaehrung = (() => {
const dl = document.getElementById('vert-futter-datalist');
if (!dl) return;
const names = [...new Set((list || []).map(e => e.futter_name))];
dl.innerHTML = names.map(n => `<option value="${UI.escape(n)}">`).join('');
dl.innerHTML = names.map(n => `<option value="${_esc(n)}">`).join('');
}).catch(() => {});
setTimeout(() => {
@ -897,7 +905,7 @@ window.Page_ernaehrung = (() => {
try {
data = await API.dogs.futterAnalyse(dog.id);
} 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;
}
@ -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">
<use href="/icons/phosphor.svg#warning-circle"></use>
</svg>
<span>${UI.escape(data.hinweis)}</span>
<span>${_esc(data.hinweis)}</span>
</div>
` : '';
@ -970,7 +978,7 @@ window.Page_ernaehrung = (() => {
return `<span style="font-size:10px;font-weight:600;padding:2px 6px;
border-radius:999px;border:1px solid ${chipColor};
color:${chipColor};white-space:nowrap">
${UI.escape(KAT_LABELS[kat] || kat)} ×${cnt}
${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
</span>`;
}).join('');
return `
@ -980,17 +988,17 @@ window.Page_ernaehrung = (() => {
<div style="min-width:0;flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(f.name)}
${_esc(f.name)}
</div>
<div class="text-xs-muted">
${UI.escape(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_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>` : ''}
</div>
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
</div>
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
color:${cfg.color};white-space:nowrap">
${UI.escape(cfg.label)}
${_esc(cfg.label)}
</span>
</div>
`;
@ -1076,10 +1084,10 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bowl-food"></use>
</svg>
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(item.futter_name)}</div>
<div class="text-xs-muted">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)}
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.futter_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
${item.menge_g ? ` &middot; ${item.menge_g} g` : ''}
</div>
</div>
@ -1102,13 +1110,13 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}">
<use href="/icons/phosphor.svg#heartbeat"></use>
</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}">
${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>
</div>
<div class="text-xs-muted">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)}
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
</div>
</div>
<button class="btn-icon vert-del-reaktion" data-id="${item.id}"

View file

@ -32,17 +32,16 @@ window.Page_erste_hilfe = (() => {
land: 'Österreich',
flag: 'AT',
eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' },
{ label: 'VetMedUni Wien — Kleintier-Notdienst (24h)', tel: '+431250776900', display: '+43 1 25077-6900' },
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
],
},
{
land: 'Schweiz',
flag: 'CH',
eintraege: [
{ label: 'Tox Info Suisse (in CH gratis)', tel: '145', display: '145 (in CH)' },
{ label: 'Tox Info Suisse (international)', tel: '+41442515151', display: '+41 44 251 51 51' },
{ label: 'Tierspital Zürich — Kleintier-Notfall (24h)', tel: '+41446358337', display: '+41 44 635 83 37' },
{ label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
{ label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
],
},
];
@ -254,13 +253,13 @@ window.Page_erste_hilfe = (() => {
</div>
${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('')}
</div>
`).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">
<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.
</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)">
${g.flag} · ${g.land}
</div>
<div class="flex-col-gap-2">
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${g.eintraege.map(renderEintrag).join('')}
</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>
Tiergiftzentralen jetzt anrufen
</div>
<div class="flex-col-gap-3">
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${gruppen}
</div>
<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 `
<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)">
<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
</div>
<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">
<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>
<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>

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" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</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>` : ''}
</div>
@ -102,7 +102,7 @@ window.Page_events = (() => {
</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);
@ -231,7 +231,7 @@ window.Page_events = (() => {
${_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-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>` : ''}
</div>
</div>
@ -248,10 +248,8 @@ window.Page_events = (() => {
await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) {
_map = await UI.map.create('ev-map', {
center: [51.1657, 10.4515], zoom: 6,
zoomControl: true, attributionControl: false,
});
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
}
// 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 d = new Date(ev.datum + 'T00:00:00');
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
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>`;
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
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 = `
<div style="min-width:180px">
<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>
</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);
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
@ -494,7 +496,7 @@ window.Page_events = (() => {
<label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div>
</div>
<div class="form-group mt-3">
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div>
@ -507,10 +509,10 @@ window.Page_events = (() => {
const footer = `
<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'}
</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>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</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;
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)">
<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>
<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>

View file

@ -71,7 +71,7 @@ window.Page_expenses = (() => {
if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<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('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
}
@ -87,7 +87,7 @@ window.Page_expenses = (() => {
</div>
${_dogSelectorHtml()}
<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')}
</button>
`;
@ -162,7 +162,7 @@ window.Page_expenses = (() => {
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</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>
${monatLine}
<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')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
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
? `<div class="list-item-text">${UI.escape(e.notiz)}</div>`
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
: '';
return `
<div class="list-item-card list-item-card--clickable exp-entry" data-id="${e.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}">
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="list-item-body">
<div class="list-item-title">${k.label}</div>
${notiz}
<div class="list-item-meta-row">
<span>${datum}</span>
${dogBadge ? `· ${dogBadge}` : ''}
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
${notiz}
</div>
<div class="list-item-amount list-item-amount--negative">${_fmt(e.betrag)}</div>
<div class="list-item-actions">
<button class="list-item-action-btn list-item-action-btn--danger exp-entry-del"
data-del="${e.id}" title="Löschen" aria-label="Eintrag löschen">
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
aria-label="Eintrag löschen">
${UI.icon('trash')}
</button>
</div>
@ -313,15 +313,15 @@ window.Page_expenses = (() => {
return `
<div class="exp-month-group">
<div class="list-group-header" style="display:flex;justify-content:space-between;align-items:baseline">
<span>${titel}</span>
<span style="text-transform:none;font-weight:700;color:var(--c-text)">${_fmt(summe)}</span>
<div class="exp-month-header">
<span class="exp-month-title">${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span>
</div>
${rows}
</div>`;
}).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)
el.querySelectorAll('.exp-entry').forEach(row => {
@ -372,26 +372,30 @@ window.Page_expenses = (() => {
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—';
return `
<div class="list-item-card${r.aktiv ? '' : ' list-item-card--inactive'}" data-rid="${r.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="list-item-body">
<div class="list-item-title">${k.label}</div>
${r.notiz ? `<div class="list-item-text">${UI.escape(r.notiz)}</div>` : ''}
<div class="list-item-meta-row">
<span>${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
· <span>${UI.icon('calendar')} ${naechste}</span>
${r.dog_name ? `· <span>${UI.icon('paw-print')} ${UI.escape(r.dog_name)}</span>` : ''}
${!r.aktiv ? '· <span>Pausiert</span>' : ''}
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
</div>
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
<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 class="list-item-amount list-item-amount--negative">${_fmt(r.betrag)}</div>
<div class="list-item-actions">
<button class="list-item-action-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="list-item-action-btn list-item-action-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
<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'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
</div>
</div>
</div>`;
}).join('');
@ -403,7 +407,7 @@ window.Page_expenses = (() => {
</button>
</div>
${recurring.length
? `<div class="list-shell">${cards}</div>`
? `<div class="exp-list">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge',
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('');
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('');
const body = `
@ -454,8 +458,9 @@ window.Page_expenses = (() => {
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Betrag</label>
${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })}
<label class="form-label">Betrag ()</label>
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
value="${r?.betrag || ''}" placeholder="0,00" required>
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
@ -472,15 +477,15 @@ window.Page_expenses = (() => {
</div>
${dogOptions ? `
<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">
<option value="">Kein Hund</option>${dogOptions}
</select>
</div>` : ''}
<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"
value="${UI.escape(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div>
</form>`;
@ -496,7 +501,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(e.target);
const payload = {
kategorie: fd.kategorie,
betrag: UI.parseMoney(fd.betrag),
betrag: parseFloat(fd.betrag),
haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum,
notiz: fd.notiz || null,
@ -683,13 +688,13 @@ window.Page_expenses = (() => {
const defaultDogId = entry?.dog_id ?? _selectedDogId;
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('');
// Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => `
<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-label">${k.label}</span>
</label>`).join('');
@ -702,10 +707,15 @@ window.Page_expenses = (() => {
<div class="exp-kat-grid">${katKacheln}</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">
<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 class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label>
@ -725,7 +735,7 @@ window.Page_expenses = (() => {
<div class="form-group">
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
<input type="text" name="notiz" class="form-control"
value="${UI.escape(entry?.notiz || '')}"
value="${_esc(entry?.notiz || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div>
@ -800,7 +810,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(ev.target);
const payload = {
kategorie: fd.kategorie,
betrag: UI.parseMoney(fd.betrag),
betrag: parseFloat(fd.betrag),
datum: fd.datum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
@ -852,5 +862,14 @@ window.Page_expenses = (() => {
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 };
})();

View file

@ -39,7 +39,12 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
// 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 '—';
const d = new Date(iso);
const now = new Date();
@ -94,7 +99,7 @@ function _fmtDate(iso) {
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${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>
</div>
</div>
@ -103,7 +108,7 @@ function _fmtDate(iso) {
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<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('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
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));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${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-trophy">🏆</div>
<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-meta">${metaLine}</div>
</div>
@ -246,16 +251,16 @@ function _fmtDate(iso) {
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
@ -275,7 +280,7 @@ function _fmtDate(iso) {
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
class="text-sm">
style="font-size:var(--text-sm)">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
@ -286,7 +291,7 @@ function _fmtDate(iso) {
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${UI.escape(monthName)}</div>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteHint}
<div class="hdm-section">
@ -315,16 +320,16 @@ function _fmtDate(iso) {
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer text-xs-muted">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div class="text-xs-muted">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</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 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"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
@ -406,8 +411,8 @@ function _fmtDate(iso) {
el.innerHTML = `
<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>
<p class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary mt-4" id="forum-first-btn">
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
Ersten Beitrag erstellen
</button>
</div>`;
@ -438,31 +443,31 @@ function _fmtDate(iso) {
function _threadCardHTML(t) {
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 lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = 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>`
: `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy"
onerror="this.src='${UI.escape(t.foto_preview)}'">`
onerror="this.src='${_esc(t.foto_preview)}'">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<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}
</div>
<div class="forum-card-content">
<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>` : ''}
<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('chat-circle-dots')} ${t.antworten || 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 = `
<div style="text-align:center;padding:var(--space-8)">
<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>`;
return;
}
@ -528,19 +533,19 @@ function _fmtDate(iso) {
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</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>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`;
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
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>`;
}
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)
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
@ -560,7 +565,7 @@ function _fmtDate(iso) {
<div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${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>
<div id="forum-reply-previews" class="forum-upload-previews"></div>
</div>
@ -573,20 +578,20 @@ function _fmtDate(iso) {
<div class="forum-thread-detail">
${modToolbar}
<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>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div>
<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}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${UI.escape(thread.autor_name || 'Unbekannt')}</span>
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<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>` : ''}
<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}">
@ -618,7 +623,7 @@ function _fmtDate(iso) {
</div>
` : `<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
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
@ -773,7 +778,7 @@ function _fmtDate(iso) {
const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length)
? `<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>`
: '';
@ -783,13 +788,13 @@ function _fmtDate(iso) {
return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(p.autor_name))}</div>
<span class="forum-post-author">${UI.escape(p.autor_name || 'Unbekannt')}</span>
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<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>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<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}
</div>
<div class="forum-post-actions">
@ -798,7 +803,7 @@ function _fmtDate(iso) {
</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">
${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>` : ''}
</div>
</div>
@ -857,7 +862,7 @@ function _fmtDate(iso) {
try {
await API.forum.deletePost(postId);
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';
}
const idx = _threads.findIndex(t => t.id === threadId);
@ -986,7 +991,7 @@ function _fmtDate(iso) {
// ----------------------------------------------------------
function _showCreateForm() {
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('');
const body = `
@ -1006,16 +1011,16 @@ function _fmtDate(iso) {
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div>
<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>
<div class="form-group">
<label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area">
<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 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>
</form>
<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;
display:flex;align-items:center;justify-content:center;
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],
});
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
);
});
_map.addLayer(_clusterGroup);
@ -1290,14 +1295,14 @@ function _fmtDate(iso) {
? `<div class="forum-mod-reports">
${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}">
<div class="text-sm">
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
${UI.escape(r.grund)}
<div style="font-size:var(--text-sm)">
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)}
</div>
<div class="text-xs-muted">
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</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
</button>
</div>`).join('')}
@ -1329,7 +1334,7 @@ function _fmtDate(iso) {
title: 'Antwort bearbeiten',
body: `<form id="${id}">
<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>
</form>`,
footer: `
@ -1368,14 +1373,14 @@ function _fmtDate(iso) {
body: `<form id="${id}">
<div class="form-group">
<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 class="form-group">
<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 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>
</form>`,
@ -1421,7 +1426,7 @@ function _fmtDate(iso) {
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.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());
document.body.appendChild(lb);
}

View file

@ -51,17 +51,17 @@ window.Page_friends = (() => {
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
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.
</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);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${UI.escape(encodeURIComponent(myName))}
banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))}
</div>
<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>
@ -82,7 +82,7 @@ window.Page_friends = (() => {
</svg>
<input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…"
value="${UI.escape(prefill || '')}"
value="${_esc(prefill || '')}"
style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
@ -278,19 +278,17 @@ window.Page_friends = (() => {
const text = item.text || '';
const page = _ACTIVITY_PAGE[item.type] || '';
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
? `<img src="${UI.escape(item.dog_foto)}" alt="${UI.escape(item.dog_name || '')}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
${UI.escape((item.user_name || '?')[0].toUpperCase())}
${_esc((item.user_name || '?')[0].toUpperCase())}
</div>`;
const tag = page ? `button type="button"` : `div`;
@ -303,17 +301,17 @@ window.Page_friends = (() => {
${avatar}
<div class="fr-activity-icon-badge">
<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>
</div>
</div>
<div class="fr-activity-body">
<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}
</div>
${text ? `<div class="fr-activity-text">${UI.escape(text)}</div>` : ''}
<div class="fr-activity-time">${UI.escape(ago)}</div>
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
<div class="fr-activity-time">${_esc(ago)}</div>
</div>
</${page ? 'button' : 'div'}>
`;
@ -353,7 +351,7 @@ window.Page_friends = (() => {
<div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${UI.escape(r.requester_name)}
${_esc(r.requester_name)}
</div>
${_dogPills(r.dogs, 2)}
</div>
@ -392,12 +390,12 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0">
${UI.escape((r.addressee_name || '?')[0].toUpperCase())}
${_esc((r.addressee_name || '?')[0].toUpperCase())}
</div>
<div class="flex-1">
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${UI.escape(r.addressee_name)}</div>
<div class="text-xs-muted">Anfrage ausstehend</div>
color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
</div>
<button class="btn btn-ghost btn-sm"
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;
transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}"
data-friend-name="${UI.escape(f.friend_name)}"
data-dogs="${UI.escape(JSON.stringify(dogs))}"
data-profile="${UI.escape(JSON.stringify(profile))}">
data-friend-name="${_esc(f.friend_name)}"
data-dogs="${_esc(JSON.stringify(dogs))}"
data-profile="${_esc(JSON.stringify(profile))}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
<!-- 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;
margin-bottom:var(--space-1)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${UI.escape(f.friend_name)}
${_esc(f.friend_name)}
</span>
${_erfahrungSpan(f.erfahrung)}
</div>
@ -497,7 +495,7 @@ window.Page_friends = (() => {
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)}
</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>
@ -506,7 +504,7 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm fr-note-btn"
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"
onclick="event.stopPropagation()">
<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);
padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => `
<div class="text-center">
<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
<div style="text-align:center">
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${UI.escape(d.name)}
${_esc(d.name)}
</div>
</div>
`).join('')}
@ -561,10 +558,9 @@ window.Page_friends = (() => {
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => `
<div class="text-center">
<div style="text-align:center">
${d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<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>`
}
<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
? `<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>
`).join('')}
@ -589,24 +585,24 @@ window.Page_friends = (() => {
if (profile.wohnort) {
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${UI.escape(profile.wohnort)}
📍 ${_esc(profile.wohnort)}
</div>`);
}
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]}
</div>`);
}
if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.5;padding-top:var(--space-2)">
${UI.escape(profile.bio)}
${_esc(profile.bio)}
</div>`);
}
if (profile.social_link) {
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"
class="text-primary">${UI.escape(profile.social_link)}</a>
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
</div>`);
}
if (!parts.length) return '';
@ -627,7 +623,7 @@ window.Page_friends = (() => {
</div>` : '';
UI.modal.open({
title: UI.escape(friendName),
title: _esc(friendName),
body: `
<div>
${badgesHTML}
@ -642,7 +638,7 @@ window.Page_friends = (() => {
Nachricht schreiben
</button>
<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>
Entfernen
</button>
@ -683,11 +679,11 @@ window.Page_friends = (() => {
padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_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;
margin-bottom:2px">
<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_partner ? `<span style="font-size:10px;font-weight:700;background:#0ea5e9;color:#fff;padding:1px 5px;border-radius:4px">Partner</span>` : ''}
${_erfahrungSpan(u.erfahrung)}
@ -697,12 +693,12 @@ window.Page_friends = (() => {
${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
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>
<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>
</button>
</div>
@ -786,14 +782,12 @@ window.Page_friends = (() => {
// ----------------------------------------------------------
function _userAvatar(name, firstDog, avatarUrl) {
if (avatarUrl) {
return `<img src="${UI.escape(avatarUrl)}" alt="${UI.escape(name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
if (firstDog?.foto_url) {
return `<img src="${UI.escape(firstDog.foto_url)}" alt="${UI.escape(firstDog.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
@ -803,7 +797,7 @@ window.Page_friends = (() => {
border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)">
${UI.escape((name || '?')[0].toUpperCase())}
${_esc((name || '?')[0].toUpperCase())}
</div>`;
}
@ -823,7 +817,7 @@ window.Page_friends = (() => {
function _wohnortLine(wohnort) {
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) {
@ -832,7 +826,7 @@ window.Page_friends = (() => {
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-1);line-height:1.4;
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) {
@ -844,7 +838,7 @@ window.Page_friends = (() => {
${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
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>
`).join('')}
${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 = '') {
return `<div class="empty-state">
<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">
<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>
<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>

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)">
Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p>
<div class="flex-col-gap-2">
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.partners.map((p, i) => {
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;
@ -91,8 +91,8 @@ window.Page_gruender = (() => {
padding:var(--space-3);border-radius:var(--radius-md);
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 class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(p.label)}</div>
<div style="flex:1;min-width:0">
<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);
height:6px;margin-top:var(--space-1);overflow:hidden">
<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)">
<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">
${UI.escape(f.name)}
${_esc(f.name)}
</span>
</div>
`).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">
#${d.total + i + 1}
</span>
<span class="text-sm-muted">frei</span>
<span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
</div>
`).join('')}
</div>
</div>` : `
<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!
</p>
</div>`}
`;
}
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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 style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search
? `Zu "${UI.escape(_search)}" wurde nichts gefunden.`
? `Zu "${_esc(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'}
</p>
</div>
@ -136,7 +136,7 @@ window.Page_hilfe = (() => {
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)">
${UI.escape(label)}
${_esc(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
`;
@ -148,12 +148,12 @@ window.Page_hilfe = (() => {
// Highlight Suchtreffer in der Frage
const frageHtml = _search
? _highlight(a.frage, _search)
: UI.escape(a.frage);
: _esc(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search
? _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
const openByDefault = !!_search;
@ -169,7 +169,7 @@ window.Page_hilfe = (() => {
display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600;
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"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
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) {
if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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>'
);
}

View file

@ -26,12 +26,12 @@ window.Page_impressum = (() => {
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)">
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.
</p>
<form id="contact-form" class="flex-col-gap-3">
<div class="grid-2">
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<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>
<input id="cf-name" type="text" required maxlength="100"

View file

@ -7,6 +7,7 @@ window.Page_jobs = (() => {
let _container = null;
let _appState = null;
const _esc = s => UI.escape(s ?? '');
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>`;
@ -43,7 +44,7 @@ window.Page_jobs = (() => {
</div>
<!-- Stellenbeschreibung -->
<div class="card mb-4">
<div class="card" style="margin-bottom:var(--space-4)">
<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>
<div style="display:grid;gap:var(--space-3)">
@ -75,7 +76,7 @@ window.Page_jobs = (() => {
</div>
<!-- Wen wir suchen -->
<div class="card mb-4">
<div class="card" style="margin-bottom:var(--space-4)">
<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>
<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;
return `
<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>
</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>
${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);
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>`;
}
@ -146,13 +147,13 @@ window.Page_jobs = (() => {
<div class="form-group">
<label class="form-label">Dein Name *</label>
<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 class="form-group">
<label class="form-label">E-Mail *</label>
<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 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>
<input class="form-control" type="file" name="files" id="jobs-files"
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)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
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);
margin-bottom:var(--space-4)">
💡 <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.
</div>` : ''}

View file

@ -135,11 +135,11 @@ window.Page_knigge = (() => {
const cards = BEGEGNUNGEN.map((b, i) => `
<div class="knigge-accordion" id="acc-${i}">
<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>
</button>
<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>
`).join('');
@ -173,16 +173,16 @@ window.Page_knigge = (() => {
// ----------------------------------------------------------
function _renderVoting() {
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">
${UI.escape(s.frage)}
${_esc(s.frage)}
</p>
<div class="knigge-vote-options" id="opts-${s.id}" style="padding:0 var(--space-5) var(--space-5)">
${s.antworten.map(a => `
<button class="knigge-vote-btn btn btn-secondary"
data-sz="${s.id}" data-key="${a.key}"
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
${UI.escape(a.text)}
${_esc(a.text)}
</button>
`).join('')}
</div>
@ -260,12 +260,12 @@ window.Page_knigge = (() => {
? 'var(--c-success, #22c55e)'
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
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)">
<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 class="text-secondary">${pct}% (${cnt})</span>
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
</div>
<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>
@ -282,7 +282,7 @@ window.Page_knigge = (() => {
<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">
${badge}
<span class="text-secondary">${UI.escape(szenario.erklaerung)}</span>
<span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
</div>
`;
}
@ -300,8 +300,8 @@ window.Page_knigge = (() => {
<textarea id="ki-situation-input" class="form-control"
rows="3"
placeholder="Beschreibe deine Situation…"
class="mb-3"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" class="w-full">
style="margin-bottom:var(--space-3)"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
Rat holen ${UI.icon('robot')}
</button>
<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)">
<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>
${UI.escape(data.rat)}
${_esc(data.rat)}
</div>
`;
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)">
${is402
? '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>
`;
result.style.display = 'block';
@ -400,7 +400,16 @@ window.Page_knigge = (() => {
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };

View file

@ -14,7 +14,7 @@ window.Page_laeufi = (() => {
_appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_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;
}
API.breeder.status().then(s => {
@ -53,7 +53,7 @@ window.Page_laeufi = (() => {
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${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;
color:var(--c-text);white-space:nowrap;overflow:hidden;
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">
<use href="/icons/phosphor.svg#lock-key"></use>
</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>`;
@ -89,7 +89,7 @@ window.Page_laeufi = (() => {
_renderHundeList();
} catch (err) {
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}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
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">
<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>` : ''}
${alter ? `<span class="text-xs-muted">${alter}</span>` : ''}
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''}
</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].filter(Boolean).map(s => UI.escape(s)).join(' · ')}
</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 id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}"
class="p-4">
<p class="text-sm-muted">Lädt</p>
style="padding:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
</div>
</div>`;
@ -177,7 +177,7 @@ window.Page_laeufi = (() => {
]);
_renderHundContent(el, hundId, laeufiList, deckList);
} 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 => `
<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">
<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">
<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>
<span class="text-xs-muted">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
${l.ende ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">→ ${_fmtDate(l.ende)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</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>` : ''}
</div>
@ -286,7 +286,7 @@ window.Page_laeufi = (() => {
${UI.icon('pencil-simple')}
</button>
<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')}
</button>
</div>
@ -314,7 +314,7 @@ window.Page_laeufi = (() => {
margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header -->
<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)">
<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;
@ -335,7 +335,7 @@ window.Page_laeufi = (() => {
</div>
<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-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>
<!-- Meilensteine -->
@ -358,7 +358,7 @@ window.Page_laeufi = (() => {
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag}
</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'}">
${UI.escape(m.label)}
</span>
@ -377,8 +377,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: `
<form id="laeufi-form" class="flex-col-gap-3">
<div class="grid-2">
<form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
@ -421,8 +421,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: `
<form id="deck-form" class="flex-col-gap-3">
<div class="grid-2">
<form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
@ -435,7 +435,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
@ -451,7 +451,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig">
@ -503,7 +503,7 @@ window.Page_laeufi = (() => {
async function _showProgModal(hundId, laeufi) {
UI.modal.open({
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: `
<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>`,
@ -535,7 +535,7 @@ window.Page_laeufi = (() => {
<tbody>
${tests.map(t => `
<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">
${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>` : ''}
@ -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);text-align:right">
<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>
</tr>`).join('')}
</tbody>
@ -572,8 +572,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: 'Progesterontest eintragen',
body: `
<form id="prog-form" class="flex-col-gap-3">
<div class="grid-2">
<form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}">
@ -586,7 +586,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wert</label>
<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 `
<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>
<h3 style="margin:0 0 var(--space-2)">${UI.escape(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${UI.escape(text)}</p>
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
</div>`;
}
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) {
const map = {
@ -33,7 +37,7 @@ window.Page_litters = (() => {
abgeschlossen: { label: 'Abgeschlossen', 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) {
@ -55,7 +59,7 @@ window.Page_litters = (() => {
abgegeben: { label: 'Abgegeben', 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 logoUrl = _breederInfo?.logo_url || null;
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;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
@ -114,15 +118,15 @@ window.Page_litters = (() => {
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${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;
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)">
<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>
</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>`;
@ -228,7 +232,7 @@ window.Page_litters = (() => {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4);
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>`;
return;
}
@ -244,8 +248,8 @@ window.Page_litters = (() => {
el.innerHTML = `
<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>
<p class="text-secondary">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary mt-4" id="litters-first-btn">
<p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen
</button>
</div>`;
@ -311,7 +315,7 @@ window.Page_litters = (() => {
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
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
let datumChip = '';
@ -321,10 +325,10 @@ window.Page_litters = (() => {
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = '';
if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span class="text-danger">überfällig</span>`
: days === 0 ? `<span class="text-success">heute!</span>`
const c = days < 0 ? `<span style="color:var(--c-danger)">überfällig</span>`
: days === 0 ? `<span style="color:var(--c-success)">heute!</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}`;
}
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 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 `
@ -351,11 +355,11 @@ window.Page_litters = (() => {
<div style="min-width:0">
${(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)">
${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_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${UI.escape(l.wurf_name)}</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)">${_esc(l.wurf_name)}</span>` : ''}
</div>` : ''}
<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)}
${sichtbarChip}
</div>
@ -386,21 +390,21 @@ window.Page_litters = (() => {
${UI.icon('pencil-simple')}
</button>
<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')}
</button>
</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>
<!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<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>
<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
</button>
</div>
@ -408,10 +412,10 @@ window.Page_litters = (() => {
<!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<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>
<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
</button>
</div>
@ -451,13 +455,13 @@ window.Page_litters = (() => {
const puppies = await API.litters.puppies(litterId);
_renderPuppies(inner, litterId, puppies);
} 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) {
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;
}
@ -465,10 +469,10 @@ window.Page_litters = (() => {
<div class="litters-puppy-row" data-puppy-id="${p.id}">
<div class="litters-puppy-info">
${_genderIcon(p.geschlecht)}
<span class="litters-puppy-name">${p.name ? UI.escape(p.name) : '<em class="text-muted">Unbenannt</em>'}</span>
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(p.farbe)}</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)">${_esc(p.farbe)}</span>` : ''}
${_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 class="litters-puppy-actions">
<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 body = `
<div id="weight-history" class="mb-3">
<p class="text-sm-muted">Lädt</p>
<div id="weight-history" style="margin-bottom:var(--space-3)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<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">
<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>
<input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
</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>
<input class="form-control" name="gemessen_am" type="date" required value="${today}">
</div>
@ -561,7 +565,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('scales')} Gewichtsverlauf — ${UI.escape(puppyLabel)}`,
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
body,
footer,
});
@ -596,7 +600,7 @@ window.Page_litters = (() => {
try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
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;
}
@ -626,22 +630,22 @@ window.Page_litters = (() => {
el.innerHTML = `
<!-- Stats-Zeile -->
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<div class="text-center">
<div class="text-xs-muted">Aktuell</div>
<div style="text-align:center">
<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>
<div class="text-center">
<div class="text-xs-muted">Zunahme</div>
<div style="text-align:center">
<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)'}">
${gain >= 0 ? '+' : ''}${gain} g
</div>
</div>
<div class="text-center">
<div class="text-xs-muted">Ø tägl.</div>
<div style="text-align:center">
<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>
<div class="text-center">
<div class="text-xs-muted">Messungen</div>
<div style="text-align:center">
<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>
</div>
@ -691,7 +695,7 @@ window.Page_litters = (() => {
</tbody>
</table>`;
} 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>` : ''}`;
}
} 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="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 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>`;
return;
}
container.innerHTML = header + `
<div class="flex-col-gap-2">
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${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-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)">
<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)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span class="text-xs-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_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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(e.wunsch_farbe)}</span>` : ''}
</div>
<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.telefon ? `<span>${UI.icon('phone')} ${UI.escape(e.telefon)}</span>` : ''}
${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</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>
</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.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.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')} ${_esc(e.notiz)}</div>` : ''}
</div>
<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-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>`).join('')}
</div>`;
@ -816,22 +820,22 @@ window.Page_litters = (() => {
UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
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">
<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 class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<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 class="form-group">
<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 class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht">
@ -842,14 +846,14 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<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 class="form-group">
<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 class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
@ -863,7 +867,7 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<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>
</form>`,
footer: `
@ -915,15 +919,15 @@ window.Page_litters = (() => {
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => {
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('');
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>
${opts}
</select>
<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 =>
@ -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>
<input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${UI.escape(v.wurf_name || '')}">
value="${_esc(v.wurf_name || '')}">
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Vater</label>
${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">
<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"
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>
</div>
<div class="form-group">
<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"
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>
</div>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Welpen gesamt</label>
<input class="form-control" type="number" name="welpen_gesamt" min="0"
@ -1001,19 +1005,19 @@ window.Page_litters = (() => {
<div class="form-group">
<label class="form-label">Preisspanne</label>
<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 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"
placeholder="Elternlinie, Besonderheiten, Charakter…">${UI.escape(v.beschreibung || '')}</textarea>
placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
</div>
<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"
placeholder="HD, ED, Gentest, Augenkontrolle…">${UI.escape(v.gesundheitstests || '')}</textarea>
placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
</div>
<div class="form-group">
@ -1024,9 +1028,9 @@ window.Page_litters = (() => {
</div>
<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"
value="${UI.escape(v.sichtbar_bis || '')}">
value="${_esc(v.sichtbar_bis || '')}">
</div>
</form>
@ -1130,11 +1134,11 @@ window.Page_litters = (() => {
const body = `
<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">
<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"
value="${UI.escape(v.name || '')}" placeholder="z. B. Max">
value="${_esc(v.name || '')}" placeholder="z. B. Max">
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
@ -1149,7 +1153,7 @@ window.Page_litters = (() => {
<div class="form-group">
<label class="form-label">Farbe / Fellzeichnung</label>
<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 class="form-group">
@ -1161,11 +1165,11 @@ window.Page_litters = (() => {
</select>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Chip-Nr.</label>
<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 class="form-group">
<label class="form-label">Geburtsgewicht (g)</label>
@ -1182,9 +1186,9 @@ window.Page_litters = (() => {
</div>
<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"
placeholder="Interne Notizen…">${UI.escape(v.notiz || '')}</textarea>
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
</div>
</form>
@ -1245,22 +1249,22 @@ window.Page_litters = (() => {
const body = `
<form id="contract-form" autocomplete="off">
<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
placeholder="Vor- und Nachname">
</div>
<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
placeholder="Straße, PLZ, Ort"></textarea>
</div>
<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"
placeholder="kaeufer@beispiel.de">
</div>
<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"
placeholder="z. B. 1.500 €">
</div>
@ -1275,7 +1279,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('file-text')} Kaufvertrag — ${UI.escape(puppyLabel)}`,
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
body,
footer,
});
@ -1313,11 +1317,11 @@ window.Page_litters = (() => {
const visOrder = ['public', 'inquiry', 'private'];
const body = `
<div id="${galleryId}" class="mb-4">
<p class="text-sm-muted">Lädt</p>
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<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)">
${UI.icon('upload-simple')} Foto hochladen
</label>
@ -1332,7 +1336,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('images')} Fotos — ${UI.escape(label)}`,
title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
body,
footer,
});
@ -1344,7 +1348,7 @@ window.Page_litters = (() => {
try {
const photos = await API.breederPhotos.list(entityType, entityId);
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;
}
el.innerHTML = `
@ -1354,21 +1358,21 @@ window.Page_litters = (() => {
const vis = visLabels[ph.visibility] || visLabels.private;
return `
<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">
<img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}"
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.src='/static/img/placeholder.webp'">
</a>
<button class="photos-vis-btn"
data-photo-id="${ph.id}"
data-vis="${UI.escape(ph.visibility)}"
data-vis="${_esc(ph.visibility)}"
title="Sichtbarkeit ändern"
style="position:absolute;bottom:0;left:0;right:0;
background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:10px;padding:2px 4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${UI.escape(vis.text)}
${_esc(vis.text)}
</button>
<button class="photos-del-btn"
data-photo-id="${ph.id}"
@ -1414,7 +1418,7 @@ window.Page_litters = (() => {
} catch (err) {
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 => `
<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 class="text-sm">${UI.escape(i.text)}</span>
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
</div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0">
<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('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
@ -1496,7 +1500,7 @@ window.Page_litters = (() => {
Trotzdem fortfahren
</button>
</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
</button>`,
});
@ -1536,7 +1540,7 @@ window.Page_litters = (() => {
} catch (err) {
UI.modal.open({
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>`,
});
return;
@ -1544,7 +1548,7 @@ window.Page_litters = (() => {
UI.modal.open({
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: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren

View file

@ -130,24 +130,54 @@ window.Page_lost = (() => {
document.getElementById('lost-btn-report')
?.addEventListener('click', _showReportForm);
await _initMap();
await _loadLeaflet();
_initMap();
setTimeout(() => _map?.invalidateSize(), 100);
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();
const mapEl = document.getElementById('lost-map');
if (!mapEl || _map) return;
if (!mapEl || !window.L || _map) return;
_map = await UI.map.create('lost-map', {
center: [51.1657, 10.4515], zoom: 6,
zoomControl: true, attributionControl: false,
});
_leafletLoaded = true;
_map = L.map('lost-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
}
// ----------------------------------------------------------
@ -273,21 +303,26 @@ window.Page_lost = (() => {
_reports.forEach(r => {
const dotColor = r._isPending ? '#d97706' : '#e74c3c';
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;
display:flex;align-items:center;justify-content:center;
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
? (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)
.bindPopup(`
<b>🔍 ${UI.escape(r.name)}</b><br>
${r.rasse ? UI.escape(r.rasse) + '<br>' : ''}
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
@ -378,14 +413,14 @@ window.Page_lost = (() => {
border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
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);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${UI.escape(r.name)}
${_escape(r.name)}
</span>
${r.rasse
? `<span class="badge">${UI.escape(r.rasse)}</span>`
? `<span class="badge">${_escape(r.rasse)}</span>`
: ''}
${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>'
@ -399,11 +434,11 @@ window.Page_lost = (() => {
</div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
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>
<div class="text-xs-secondary">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
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>
${r._isPending
? `<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
</button>
</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"
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()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
@ -447,19 +482,19 @@ window.Page_lost = (() => {
: ''}
<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>
${r.rasse ? `<span class="badge">${UI.escape(r.rasse)}</span>` : ''}
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
</div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${UI.escape(r.beschreibung)}
${_escape(r.beschreibung)}
</p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</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 style="display:flex;gap:var(--space-2);flex-wrap:wrap">
@ -473,7 +508,7 @@ window.Page_lost = (() => {
</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', () => {
UI.modal.close();
@ -511,10 +546,10 @@ window.Page_lost = (() => {
// ----------------------------------------------------------
function _showFoundDialog(r) {
UI.modal.open({
title: `🎉 ${UI.escape(r.name)} gefunden?`,
title: `🎉 ${_escape(r.name)} gefunden?`,
body: `
<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.
</p>
`,
@ -555,7 +590,7 @@ window.Page_lost = (() => {
const dogs = _appState.dogs || [];
const dogOpts = dogs.length > 0
? `<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 = `
@ -565,7 +600,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Registrierter Hund
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts}
@ -581,7 +616,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Rasse
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador">
@ -608,7 +643,7 @@ window.Page_lost = (() => {
</div>
<input type="hidden" name="lat" id="lf-lat">
<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
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
@ -618,7 +653,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Foto
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
@ -790,7 +825,17 @@ window.Page_lost = (() => {
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">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<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">
<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>
<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>

View file

@ -221,7 +221,7 @@ window.Page_map = (() => {
<div class="map-statusbar" id="map-statusbar">
<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-weather-chip--hidden" id="map-weather-info"></span>
</div>
@ -1344,15 +1344,15 @@ window.Page_map = (() => {
});
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', () => {
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${UI.escape(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${UI.escape(b.stadt)}</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">${_esc(b.stadt)}</div>` : '';
marker.bindPopup(`
<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}
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
</div>
@ -1780,7 +1780,7 @@ window.Page_map = (() => {
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
class="rec-dog-cb hidden">
style="display:none" class="rec-dog-cb">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('')}
@ -1798,7 +1798,7 @@ window.Page_map = (() => {
<input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required>
</div>
<div class="grid-2">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
@ -1842,7 +1842,7 @@ window.Page_map = (() => {
</label>
</div>
<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"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>

View file

@ -161,20 +161,20 @@ window.Page_moderation = (() => {
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:var(--space-4)">
${fotos.map(f => `
<div class="card p-4" data-id="${f.id}">
<a href="#wiki?rasse=${UI.escape(f.rasse_slug)}" style="display:block;text-decoration:none">
<img src="${UI.escape(f.foto_url)}" alt=""
<div class="card" style="padding:var(--space-4)" data-id="${f.id}">
<a href="#wiki?rasse=${_esc(f.rasse_slug)}" style="display:block;text-decoration:none">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;
border-radius:var(--radius-md);margin-bottom:var(--space-3)">
</a>
<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 style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2)">
von ${UI.escape(f.user_name)}
von ${_esc(f.user_name)}
</div>
<div class="mb-3">
<div style="margin-bottom:var(--space-3)">
${f.rights_confirmed
? `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;
background:#dcfce7;color:#166534"> Bildrechte bestätigt</span>`
@ -183,17 +183,17 @@ window.Page_moderation = (() => {
</div>
${f.aktuell_foto ? `
<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;
border-radius:var(--radius-sm);opacity:.5;
margin-bottom:var(--space-3)">
` : `<div style="font-size:var(--text-xs);color:var(--c-warning);
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"
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"
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>
`).join('')}
@ -287,7 +287,7 @@ window.Page_moderation = (() => {
el.innerHTML = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);
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 => {
const isAdminUser = u.rolle === 'admin' || u.is_admin;
const canAction = isAdmin && !isAdminUser;
@ -299,23 +299,23 @@ window.Page_moderation = (() => {
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${UI.escape(u.name[0].toUpperCase())}
${_esc(u.name[0].toUpperCase())}
</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);
color:var(--c-text)">
${UI.escape(u.name)}
${_esc(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;
border-radius:3px;background:var(--c-danger);
color:#fff;margin-left:4px">GESPERRT</span>` : ''}
</div>
<div class="text-xs-muted">
${UI.escape(u.email)} ·
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(u.email)} ·
<span style="color:${
u.rolle === 'admin' ? 'var(--c-danger)'
: u.rolle === 'moderator' ? '#f59e0b'
: 'var(--c-text-muted)'}">
${UI.escape(u.rolle)}
${_esc(u.rolle)}
</span>
</div>
</div>
@ -323,13 +323,13 @@ window.Page_moderation = (() => {
${canAction
? (u.is_banned
? `<button class="btn btn-sm btn-ghost mod-unban"
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
title="Sperre aufheben" class="text-success">
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" style="color:var(--c-success)">
${UI.icon('lock-open')}
</button>`
: `<button class="btn btn-sm btn-ghost mod-ban"
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
title="Sperren" class="text-danger">
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" style="color:var(--c-danger)">
${UI.icon('lock')}
</button>`)
: ''
@ -400,27 +400,27 @@ window.Page_moderation = (() => {
return;
}
el.innerHTML = `
<div class="flex-col-gap-3">
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${reports.map(r => `
<div class="card" style="padding:var(--space-4);
border-left:3px solid var(--c-danger)">
<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);
margin-bottom:var(--space-1)">
${UI.escape(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${UI.escape(r.melder_name)}</strong>
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${UI.escape(r.grund)}
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);
border-radius:var(--radius-sm)">
${UI.escape(r.content_preview)}
${_esc(r.content_preview)}
</div>` : ''}
</div>
<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)' };
el.innerHTML = `
<div class="flex-col-gap-3">
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${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>
<div style="font-weight:600">${UI.escape(e.poi_name)}</div>
<div class="text-xs-muted">
OSM-ID: ${UI.escape(e.osm_id)} · Feld: ${UI.escape(e.field)} · von ${UI.escape(e.einreicher_name)}
<div style="font-weight:600">${_esc(e.poi_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
</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="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 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 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-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>
${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 };

View file

@ -88,7 +88,7 @@ window.Page_movies = (() => {
<div class="movies-search-row">
<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"
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 class="movies-filter-row">
<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 === '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 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 === '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>
@ -201,18 +201,18 @@ window.Page_movies = (() => {
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 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 streaming = film.streaming ? `<span class="text-xs-muted">${UI.escape(film.streaming)}</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 style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
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-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">
<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 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}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div>
@ -234,17 +234,17 @@ window.Page_movies = (() => {
const body = `
<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)">
<span class="badge badge-primary">${UI.escape(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 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> ${_esc(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span>
</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>
<div class="mb-2">
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)">
<strong>Community-Bewertung:</strong>
</div>
<div id="modal-stars-${UI.escape(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-stars-${_esc(film.id)}">${stars}</div>
<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
</div>
${loginHint}
@ -262,9 +262,9 @@ window.Page_movies = (() => {
const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => {
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('');
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) {
@ -339,9 +339,9 @@ window.Page_movies = (() => {
<div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body">
<div class="movie-promi-name">${UI.escape(p.name)}</div>
<div class="movie-promi-rasse">${UI.escape(p.rasse)}</div>
<div class="movie-promi-text">${UI.escape(p.bekannt_fuer)}</div>
<div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
</div>
</div>
`).join('')}
@ -370,13 +370,13 @@ window.Page_movies = (() => {
const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<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-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
<div class="hdm-vote-name">${_esc(dog.name)}</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}">
${isVoted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Gewählt' : 'Abstimmen'}
</button>
@ -405,16 +405,16 @@ window.Page_movies = (() => {
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</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>
@ -427,7 +427,7 @@ window.Page_movies = (() => {
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${UI.escape(monthName)}</div>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteSection}
@ -465,7 +465,16 @@ window.Page_movies = (() => {
// ----------------------------------------------------------
// 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
// ----------------------------------------------------------
return { init, refresh };

View file

@ -47,6 +47,14 @@ window.Page_notes = (() => {
// ----------------------------------------------------------
// 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) {
if (!isoStr) return '';
@ -68,10 +76,7 @@ window.Page_notes = (() => {
} catch (_) { return 'Älteres'; }
}
function _truncate(str, max = 600) {
// 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.
function _truncate(str, max = 150) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
}
@ -120,7 +125,7 @@ window.Page_notes = (() => {
.filter(([, items]) => items.length > 0)
.map(([label, items]) => `
<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('')}
</div>
`).join('');
@ -161,9 +166,9 @@ window.Page_notes = (() => {
<div class="notes-filter-chips">
${RUBRIKEN.map(r => `
<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}` : ''}">
${UI.escape(r.label)}
${_esc(r.label)}
</button>
`).join('')}
</div>
@ -173,7 +178,7 @@ window.Page_notes = (() => {
<div class="notes-search-wrap">
<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"
placeholder="Suche…" value="${UI.escape(_searchQ)}">
placeholder="Suche…" value="${_esc(_searchQ)}">
</div>
<div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
@ -238,32 +243,21 @@ window.Page_notes = (() => {
/* Gruppen */
.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; }
/* Karten — Notes-spezifischer Override: vertikales Layout statt horizontalem .list-item-card */
.notes-card { flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; }
/* 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; } */
/* Karten */
.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); }
.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; }
/* 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-Override: Actions in Top-Zeile rechts ausrichten (statt align-self:center bei list-item-actions) */
.notes-card-actions { margin-left: auto; align-self: flex-start; }
/* Notes-Override: Newlines (pre-wrap) + max 5 Zeilen mit "…", Rest in Detail-Modal */
.notes-card-text { line-height: 1.55; white-space: pre-wrap; margin: 0; color: var(--c-text);
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; }
/* Detail-Modal: voller Notiz-Text scrollbar */
.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-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-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; }
.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); }
.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); }
@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' : ''}>
${_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>
${_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 ? `
<div class="notes-ki-suggestions">
<ul>
${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')}
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
</ul>
</div>
` : ''}
@ -320,42 +314,43 @@ window.Page_notes = (() => {
const hasLocation = !!note.location_name;
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 -->
<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>
${UI.escape(rb.label)}
${_esc(rb.label)}
</span>
${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">
<button class="list-item-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<div class="notes-card-actions">
<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>
</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>
</button>
</div>
</div>
<!-- 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 -->
${microBadges.length ? `
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
<div class="notes-micro-badges">
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
</div>
` : ''}
<!-- 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>
${UI.escape(_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)}` : ''}
${_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> ${_esc(note.location_name)}` : ''}
</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>
<!-- 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>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${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)'};
background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'};
color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer">
${UI.escape(r.label)}
${_esc(r.label)}
</button>`).join('')}
</div>
</div>
<!-- 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>
<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);
@ -587,9 +524,9 @@ window.Page_notes = (() => {
box-sizing:border-box"></textarea>
</div>
<div class="flex-gap-3">
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
<div style="display:flex;gap:var(--space-3)">
<button id="nc-cancel" class="btn btn-ghost" style="flex:1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary" style="flex:1">Speichern</button>
</div>
</div>`;
};
@ -664,7 +601,7 @@ window.Page_notes = (() => {
<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;
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>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten
@ -682,7 +619,7 @@ window.Page_notes = (() => {
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
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>
${note.parent_type === 'training_session' ? `
@ -690,7 +627,7 @@ window.Page_notes = (() => {
<div>
<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>
<div class="flex-gap-2">
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(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);
@ -705,7 +642,7 @@ window.Page_notes = (() => {
<div>
<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>
<div class="flex-gap-2">
<div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,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);
@ -720,7 +657,7 @@ window.Page_notes = (() => {
<div>
<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>
<div class="flex-gap-2">
<div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,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);

View file

@ -85,7 +85,7 @@ window.Page_onboarding = (() => {
// ----------------------------------------------------------
function _step1() {
return `
<div class="text-center">
<div style="text-align:center">
<!-- Logo -->
<div style="margin-bottom:var(--space-6)">
@ -133,19 +133,19 @@ window.Page_onboarding = (() => {
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
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>
`).join('')}
</div>
<!-- Buttons -->
<div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-next-btn" class="w-full">
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<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>
Los geht's
</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
</button>
</div>
@ -222,7 +222,7 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<span id="ob-photo-label">Foto auswählen</span>
<input type="file" name="foto" id="ob-photo-input"
accept="image/*" class="hidden">
accept="image/*" style="display:none">
</label>
</div>
@ -234,13 +234,13 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
<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>
Hund anlegen
</button>
</div>
<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
</button>
</div>
@ -255,7 +255,7 @@ window.Page_onboarding = (() => {
function _step3() {
const dogName = _appState.activeDog?.name;
return `
<div class="text-center">
<div style="text-align:center">
<!-- Erfolgs-Icon -->
<div style="margin-bottom:var(--space-6)">
@ -276,7 +276,7 @@ window.Page_onboarding = (() => {
${dogName ? `
<p style="font-size:var(--text-base);color:var(--c-text-secondary);
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
und viele weitere Funktionen nutzen.
</p>
@ -294,13 +294,13 @@ window.Page_onboarding = (() => {
</p>
<!-- CTA -->
<div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-diary-btn" class="w-full">
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<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>
Zum Tagebuch
</button>
${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>
Profil vervollständigen
</button>
@ -416,7 +416,7 @@ window.Page_onboarding = (() => {
}
App.renderDogSwitcher();
UI.toast.success(`${UI.escape(dog.name)} wurde angelegt!`);
UI.toast.success(`${_esc(dog.name)} wurde angelegt!`);
_step = 3;
_render();
@ -452,6 +452,9 @@ window.Page_onboarding = (() => {
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(s) {
return UI.escape(s || '');
}
// ----------------------------------------------------------
// 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 -->
<div style="padding:var(--space-4) var(--space-4) 0">
<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}
</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 `
<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>
<div class="flex-1">
<div style="flex:1">
<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>
@ -414,7 +414,7 @@ window.Page_personality = (() => {
<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;
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>
<!-- Teilen + Nochmal -->

View file

@ -281,8 +281,8 @@ window.Page_places = (() => {
</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.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.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.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 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>` : ''}
<div id="place-rating-${place.id}"></div>
<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 ? `
<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-primary flex-1" id="place-detail-close">Schließen</button>
@ -348,24 +348,24 @@ window.Page_places = (() => {
</div>
<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"
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div>
<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"
value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div>
<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"
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</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 style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
@ -386,10 +386,10 @@ window.Page_places = (() => {
const footer = `
<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'}
</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>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
</div>

View file

@ -15,16 +15,21 @@ window.Page_playdate = (() => {
// ------------------------------------------------------------------
// 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 '';
const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE');
}
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) {
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;"
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">
<!-- 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" data-tab="listings">Meine Inserate</button>
<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-2)">
${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'}
</span>
</div>
@ -240,34 +245,34 @@ function _fmtDate(iso) {
function _nearbyCard(d) {
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)">
${_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);
color:var(--c-text)">${UI.escape(d.dog_name)}</div>
${d.rasse ? `<div class="text-sm-secondary">${UI.escape(d.rasse)}</div>` : ''}
${d.alter ? `<div class="text-xs-muted">${UI.escape(d.alter)}</div>` : ''}
color:var(--c-text)">${_esc(d.dog_name)}</div>
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
</div>
</div>
<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)">
${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>
${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>
${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
${UI.escape(d.beschreibung)}
${_esc(d.beschreibung)}
</p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
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
</button>
</div>
@ -384,12 +389,12 @@ function _fmtDate(iso) {
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
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)">
${_dogAvatar(dog.foto_url, dog.name, 44)}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="text-xs-secondary">${UI.escape(dog.rasse)}</div>` : ''}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
</div>
<span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px;
@ -402,12 +407,12 @@ function _fmtDate(iso) {
${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')}
${listing.ort_name ? UI.escape(listing.ort_name) + ' · ' : ''}
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km
</div>
${listing.beschreibung ? `
<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)">
Noch kein Inserat trage dich ein, damit andere dich finden können.
@ -437,10 +442,10 @@ function _fmtDate(iso) {
<form id="${formId}">
<div class="form-group">
<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"
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"
title="GPS-Standort ermitteln">
${UI.icon('crosshair')}
@ -467,7 +472,7 @@ function _fmtDate(iso) {
<div class="form-group">
<label class="form-label">Beschreibung (optional)</label>
<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>
</form>
`,
@ -573,7 +578,7 @@ function _fmtDate(iso) {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
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('')}
</div>
</div>` : ''}
@ -583,7 +588,7 @@ function _fmtDate(iso) {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
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('')}
</div>
</div>` : ''}
@ -626,17 +631,17 @@ function _fmtDate(iso) {
function _incomingCard(r) {
const isPending = r.status === 'pending';
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)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.from_dog_name)}</div>
<div class="text-xs-secondary">
${r.from_dog_rasse ? UI.escape(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? UI.escape(r.alter) + ' · ' : ''}
von ${UI.escape(r.from_user_name)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)}
</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>
${_statusBadge(r.status)}
</div>
@ -646,11 +651,11 @@ function _fmtDate(iso) {
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5">
"${UI.escape(r.nachricht)}"
"${_esc(r.nachricht)}"
</div>` : ''}
${isPending ? `
<div class="flex-gap-2">
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen
@ -671,23 +676,23 @@ function _fmtDate(iso) {
function _outgoingCard(r) {
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)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.to_dog_name)}</div>
<div class="text-xs-secondary">
${r.to_dog_rasse ? UI.escape(r.to_dog_rasse) + ' · ' : ''}
von ${UI.escape(r.to_user_name)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)}
</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>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<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>` : ''}
${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">
${UI.icon('phone')} <strong>110</strong> Polizei
</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
</button>
</div>
@ -94,7 +94,8 @@ window.Page_poison = (() => {
document.getElementById('poison-btn-erstehilfe')
?.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
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
@ -103,16 +104,17 @@ window.Page_poison = (() => {
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
async function _initMap() {
function _initMap() {
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>' : ''}
<small>📍 ${distStr} entfernt</small><br>
<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));
@ -274,13 +276,13 @@ window.Page_poison = (() => {
border-left:4px solid ${typ.color}">
<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 class="flex-1-min">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span class="badge"
style="background:${typ.color};color:#fff">${typ.label}</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>'
? '<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);
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 ? '…' : ''}
</p>`
: ''}
<div class="text-xs-secondary">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)} ·
läuft ab ${_fmtDate(r.expires_at)}
</div>
@ -334,7 +336,7 @@ window.Page_poison = (() => {
<span class="badge" style="background:${typ.color};color:#fff">
${UI.icon(typ.icon)} ${typ.label}
</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>
${r.beschreibung
@ -351,7 +353,7 @@ window.Page_poison = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
${!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>
${isOwnEntry || isAdmin
@ -470,7 +472,7 @@ window.Page_poison = (() => {
<div class="form-group">
<label class="form-label">
Beschreibung
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<textarea class="form-control" name="beschreibung" rows="3"
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">
<label class="form-label">
Foto
<span class="text-secondary">(optional)</span>
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
@ -591,7 +593,7 @@ window.Page_poison = (() => {
title: 'Danke für deine Meldung!',
body: `
<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>
</div>
<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