Compare commits
21 commits
15d319fbd5
...
26b515cede
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b515cede | |||
| c7a84438d1 | |||
| 1ff66a7083 | |||
| 7751d303bb | |||
| 83b1509168 | |||
| 35937ed51b | |||
| 297bd22f96 | |||
| c517c9281d | |||
| e7939ce98e | |||
| 2d98eb9374 | |||
| 8e75e2b1a7 | |||
| 2f37e0ed16 | |||
| f6633d65b0 | |||
| 73872e2c21 | |||
| c8ef4939f1 | |||
| b0ae71ba69 | |||
| 9a066cb24c | |||
| 1de39536af | |||
| 459cd425f2 | |||
| 279f76714e | |||
| 65cfa25e59 |
123 changed files with 5534 additions and 3476 deletions
11
Dockerfile
11
Dockerfile
|
|
@ -8,6 +8,11 @@ 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
|
||||
|
|
@ -22,6 +27,12 @@ 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=*"]
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -287,7 +287,8 @@ 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; \
|
||||
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
|
||||
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)"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1099
|
||||
1120
|
||||
|
|
@ -212,6 +212,49 @@ 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:
|
||||
|
|
|
|||
20
backend/config.py
Normal file
20
backend/config.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""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}
|
||||
47
backend/errors.py
Normal file
47
backend/errors.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""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
|
||||
|
|
@ -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' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"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)
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"connect-src 'self' https:; "
|
||||
"frame-ancestors 'none'; "
|
||||
|
|
@ -1763,19 +1763,40 @@ 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}</style></head>
|
||||
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>
|
||||
<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');
|
||||
// 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);
|
||||
// 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);
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
|
|
|||
37
backend/math_utils.py
Normal file
37
backend/math_utils.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""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
|
||||
|
|
@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
email: str = Field(..., max_length=254)
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
rolle: Optional[str] = None # user | moderator | admin
|
||||
rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin
|
||||
is_moderator: Optional[int] = None
|
||||
is_banned: Optional[int] = None
|
||||
ban_reason: Optional[str] = None
|
||||
ban_reason: Optional[str] = Field(None, max_length=1000)
|
||||
is_social_media: Optional[int] = None
|
||||
subscription_tier: Optional[str] = None
|
||||
subscription_tier: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
class WikiEnrichBody(BaseModel):
|
||||
limit: int = 10
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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,18 +31,6 @@ 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)
|
||||
|
|
@ -234,7 +222,7 @@ async def adoption_nearby(
|
|||
for row in rows:
|
||||
d = dict(row)
|
||||
if d.get("tierheim_lat") and d.get("tierheim_lon"):
|
||||
dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
|
||||
dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
|
||||
if dist <= radius:
|
||||
d["distanz_km"] = round(dist, 1)
|
||||
cached_animals.append(d)
|
||||
|
|
@ -250,7 +238,7 @@ async def adoption_nearby(
|
|||
# ------ Statische Tierheime (immer) ------
|
||||
shelters = []
|
||||
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
|
||||
dist = _haversine(lat, lon, slat, slon)
|
||||
dist = haversine_km(lat, lon, slat, slon)
|
||||
if dist <= radius:
|
||||
shelters.append({
|
||||
"id": sid,
|
||||
|
|
@ -304,7 +292,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
|
|||
# ==================================================================
|
||||
|
||||
class InterestBody(BaseModel):
|
||||
nachricht: Optional[str] = None
|
||||
nachricht: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -354,7 +342,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(lat, lon, d["lat"], d["lon"])
|
||||
dist = haversine_km(lat, lon, d["lat"], d["lon"])
|
||||
d["distanz_km"] = round(dist, 1)
|
||||
if dist > radius:
|
||||
continue
|
||||
|
|
@ -434,7 +422,7 @@ async def community_create(
|
|||
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
|
||||
# ------------------------------------------------------------------
|
||||
class _StatusBody(BaseModel):
|
||||
status: str
|
||||
status: str = Field(..., max_length=50)
|
||||
|
||||
@router.patch("/community/{listing_id}")
|
||||
def community_update_status(
|
||||
|
|
|
|||
|
|
@ -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,21 +12,9 @@ _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 = 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)
|
||||
lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km)
|
||||
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
|
||||
|
||||
|
||||
|
|
@ -60,7 +48,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user))
|
|||
(lat, lon, user["id"])
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
return {"poison": has_poison, "lost": has_lost}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
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
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
ref_code: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
def _gen_referral_code() -> str:
|
||||
|
|
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
|
|||
email: EmailStr
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
token: str = Field(..., min_length=10, max_length=200)
|
||||
password: str = Field(..., min_length=8, max_length=200)
|
||||
|
||||
@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
|
||||
message: Optional[str] = None
|
||||
tier: str = Field(..., max_length=50)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
@router.post("/upgrade-request")
|
||||
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
grund: str = Field(..., min_length=3, max_length=2000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -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] = None
|
||||
rasse_text: Optional[str] = None
|
||||
verein: Optional[str] = None
|
||||
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)
|
||||
vdh_mitglied: Optional[int] = None
|
||||
stadt: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
beschreibung: Optional[str] = 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)
|
||||
|
||||
@router.put("/breeder/profile")
|
||||
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
visibility: str = Field(..., max_length=30)
|
||||
|
||||
class CaptionBody(BaseModel):
|
||||
caption: Optional[str] = None
|
||||
caption: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
text: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/messages", status_code=201)
|
||||
|
|
@ -151,8 +151,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""BAN YARO — Tagebuch Routes"""
|
||||
|
||||
import os, uuid, json, math, logging, asyncio
|
||||
import os, uuid, json, logging, asyncio
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user, require_admin
|
||||
|
|
@ -11,6 +11,7 @@ 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__)
|
||||
|
||||
|
|
@ -19,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|||
|
||||
|
||||
class DiaryCreate(BaseModel):
|
||||
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
|
||||
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)
|
||||
tags: Optional[list] = None
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
location_name: Optional[str] = None
|
||||
location_name: Optional[str] = Field(None, max_length=300)
|
||||
is_milestone: bool = False
|
||||
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
|
||||
weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
|
||||
weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
|
||||
|
||||
|
||||
class DiaryUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
titel: Optional[str] = Field(None, max_length=200)
|
||||
text: Optional[str] = Field(None, max_length=10000)
|
||||
tags: Optional[list] = None
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
location_name: Optional[str] = None
|
||||
location_name: Optional[str] = Field(None, max_length=300)
|
||||
is_milestone: Optional[bool] = None
|
||||
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
|
||||
|
||||
|
|
@ -409,7 +410,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")
|
||||
|
|
@ -422,16 +423,6 @@ 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)):
|
||||
|
|
@ -445,7 +436,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"],
|
||||
|
|
@ -456,7 +447,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"],
|
||||
|
|
@ -503,7 +494,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")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import os
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
rasse: Optional[str] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
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)
|
||||
gewicht_kg: Optional[float] = None
|
||||
widerrist_cm: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: bool = False
|
||||
chip_nr: Optional[str] = Field(None, max_length=50)
|
||||
bio: Optional[str] = Field(None, max_length=2000)
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class DogUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
rasse_id: Optional[int] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
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)
|
||||
gewicht_kg: Optional[float] = None
|
||||
widerrist_cm: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
chip_nr: Optional[str] = Field(None, max_length=50)
|
||||
bio: Optional[str] = Field(None, max_length=2000)
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -180,14 +180,22 @@ 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:
|
||||
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"])
|
||||
)
|
||||
# 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"],)
|
||||
)
|
||||
|
||||
return dict(dog)
|
||||
|
||||
|
|
@ -1025,8 +1033,8 @@ async def public_dog_profile(dog_id: int):
|
|||
|
||||
|
||||
class FoundReport(BaseModel):
|
||||
message: Optional[str] = None
|
||||
kontakt: Optional[str] = None
|
||||
message: Optional[str] = Field(None, max_length=1000)
|
||||
kontakt: Optional[str] = Field(None, max_length=300)
|
||||
|
||||
|
||||
# Gefunden-Meldung (kein Login nötig)
|
||||
|
|
@ -1311,7 +1319,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 # YYYY-MM-DD
|
||||
verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD
|
||||
|
||||
@router.post("/{dog_id}/gedenken")
|
||||
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None # trocken|nass|barf|mix
|
||||
marke: Optional[str] = None
|
||||
futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix
|
||||
marke: Optional[str] = Field(None, max_length=200)
|
||||
kcal_tag: Optional[int] = None
|
||||
portionen: Optional[int] = None
|
||||
notizen: Optional[str] = None
|
||||
notizen: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
|
||||
class KiBeratungRequest(BaseModel):
|
||||
frage: str
|
||||
dog_name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
alter: Optional[str] = None
|
||||
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)
|
||||
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
|
||||
uhrzeit: str
|
||||
futter_name: str
|
||||
futter_typ: Optional[str] = "trockenfutter"
|
||||
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)
|
||||
menge_g: Optional[int] = None
|
||||
notiz: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class ReaktionCreate(BaseModel):
|
||||
datum: str
|
||||
uhrzeit: str
|
||||
reaktion_typ: str
|
||||
datum: str = Field(..., max_length=32)
|
||||
uhrzeit: str = Field(..., max_length=20)
|
||||
reaktion_typ: str = Field(..., max_length=100)
|
||||
intensitaet: Optional[int] = 3
|
||||
notiz: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,54 +1,45 @@
|
|||
"""BAN YARO — Events (Hundeveranstaltungen)"""
|
||||
|
||||
import math
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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 = 'going' # 'going' | 'maybe'
|
||||
status: str = Field('going', max_length=20) # 'going' | 'maybe'
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
titel: str
|
||||
datum: str # YYYY-MM-DD
|
||||
uhrzeit: Optional[str] = None
|
||||
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)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
ort_name: Optional[str] = None
|
||||
typ: str = 'sonstiges'
|
||||
beschreibung: Optional[str] = None
|
||||
link: Optional[str] = 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)
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
datum: Optional[str] = None
|
||||
uhrzeit: Optional[str] = None
|
||||
titel: Optional[str] = Field(None, max_length=200)
|
||||
datum: Optional[str] = Field(None, max_length=32)
|
||||
uhrzeit: Optional[str] = Field(None, max_length=20)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
ort_name: Optional[str] = None
|
||||
typ: Optional[str] = None
|
||||
beschreibung: Optional[str] = None
|
||||
link: Optional[str] = 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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -86,7 +77,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(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
if r['lat'] is None or haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
kategorie: str = Field(..., max_length=50)
|
||||
betrag: float
|
||||
datum: str
|
||||
notiz: Optional[str] = None
|
||||
datum: str = Field(..., max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class ExpenseUpdate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
kategorie: Optional[str] = None
|
||||
kategorie: Optional[str] = Field(None, max_length=50)
|
||||
betrag: Optional[float] = None
|
||||
datum: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
datum: Optional[str] = Field(None, max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class RecurringCreate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
kategorie: str
|
||||
kategorie: str = Field(..., max_length=50)
|
||||
betrag: float
|
||||
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
|
||||
startdatum: str # ISO date
|
||||
notiz: Optional[str] = None
|
||||
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)
|
||||
|
||||
class RecurringUpdate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
kategorie: Optional[str] = None
|
||||
kategorie: Optional[str] = Field(None, max_length=50)
|
||||
betrag: Optional[float] = None
|
||||
haeufigkeit: Optional[str] = None
|
||||
startdatum: Optional[str] = None
|
||||
notiz: Optional[str] = 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)
|
||||
aktiv: Optional[bool] = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import os, uuid, json, logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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 = 'allgemein'
|
||||
titel: str
|
||||
text: str
|
||||
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)
|
||||
thread_lat: Optional[float] = None
|
||||
thread_lon: Optional[float] = None
|
||||
thread_ort: Optional[str] = None
|
||||
client_time: Optional[str] = None
|
||||
thread_ort: Optional[str] = Field(None, max_length=300)
|
||||
client_time: Optional[str] = Field(None, max_length=64)
|
||||
|
||||
class PostCreate(BaseModel):
|
||||
text: str
|
||||
client_time: Optional[str] = None
|
||||
text: str = Field(..., min_length=1, max_length=10000)
|
||||
client_time: Optional[str] = Field(None, max_length=64)
|
||||
|
||||
class ThreadPatch(BaseModel):
|
||||
is_pinned: Optional[int] = None
|
||||
is_locked: Optional[int] = None
|
||||
|
||||
class ThreadUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
titel: Optional[str] = Field(None, max_length=200)
|
||||
text: Optional[str] = Field(None, max_length=10000)
|
||||
thread_lat: Optional[float] = None
|
||||
thread_lon: Optional[float] = None
|
||||
thread_ort: Optional[str] = None
|
||||
thread_ort: Optional[str] = Field(None, max_length=300)
|
||||
|
||||
class PostUpdate(BaseModel):
|
||||
text: str
|
||||
text: str = Field(..., min_length=1, max_length=10000)
|
||||
|
||||
class LikeBody(BaseModel):
|
||||
target_type: str # 'thread' | 'post'
|
||||
target_type: str = Field(..., max_length=20) # 'thread' | 'post'
|
||||
target_id: int
|
||||
|
||||
class ReportBody(BaseModel):
|
||||
target_type: str
|
||||
target_type: str = Field(..., max_length=20)
|
||||
target_id: int
|
||||
grund: str
|
||||
grund: str = Field(..., min_length=3, max_length=1000)
|
||||
|
||||
class LocationBody(BaseModel):
|
||||
lat: Optional[float] = None
|
||||
|
|
|
|||
|
|
@ -1,37 +1,28 @@
|
|||
"""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
|
||||
from pydantic import BaseModel, Field
|
||||
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 # "17:00"
|
||||
ort_name: Optional[str] = None
|
||||
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)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
radius_m: int = 500
|
||||
notiz: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class GassiZeitUpdate(BaseModel):
|
||||
|
|
@ -83,7 +74,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(lat, lon, d["lat"], d["lon"])
|
||||
dist = haversine_m(lat, lon, d["lat"], d["lon"])
|
||||
if not nur_eigene and dist > radius:
|
||||
continue
|
||||
d["distance_m"] = int(dist)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import os, uuid
|
||||
from datetime import date, datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
bezeichnung: Optional[str] = None
|
||||
datum: str
|
||||
naechstes: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
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)
|
||||
# Gewicht
|
||||
wert: Optional[float] = None
|
||||
einheit: Optional[str] = "kg"
|
||||
einheit: Optional[str] = Field("kg", max_length=20)
|
||||
# Impfung
|
||||
charge_nr: Optional[str] = None
|
||||
tierarzt_name: Optional[str] = None
|
||||
charge_nr: Optional[str] = Field(None, max_length=100)
|
||||
tierarzt_name: Optional[str] = Field(None, max_length=200)
|
||||
# Tierarztbesuch
|
||||
kosten: Optional[float] = None
|
||||
diagnose: Optional[str] = None
|
||||
diagnose: Optional[str] = Field(None, max_length=2000)
|
||||
# Medikament
|
||||
dosierung: Optional[str] = None
|
||||
haeufigkeit: Optional[str] = None
|
||||
dosierung: Optional[str] = Field(None, max_length=200)
|
||||
haeufigkeit: Optional[str] = Field(None, max_length=200)
|
||||
aktiv: Optional[int] = 1
|
||||
bis_datum: Optional[str] = None
|
||||
bis_datum: Optional[str] = Field(None, max_length=32)
|
||||
# Allergie
|
||||
schweregrad: Optional[str] = None # leicht | mittel | schwer
|
||||
reaktion: Optional[str] = None
|
||||
schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer
|
||||
reaktion: Optional[str] = Field(None, max_length=1000)
|
||||
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] = None
|
||||
wurftermin: Optional[str] = None
|
||||
deckdatum: Optional[str] = Field(None, max_length=32)
|
||||
wurftermin: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
|
||||
class HealthUpdate(BaseModel):
|
||||
bezeichnung: Optional[str] = None
|
||||
datum: Optional[str] = None
|
||||
naechstes: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
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)
|
||||
wert: Optional[float] = None
|
||||
einheit: Optional[str] = None
|
||||
charge_nr: Optional[str] = None
|
||||
tierarzt_name: Optional[str] = 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)
|
||||
kosten: Optional[float] = None
|
||||
diagnose: Optional[str] = None
|
||||
dosierung: Optional[str] = None
|
||||
haeufigkeit: Optional[str] = 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)
|
||||
aktiv: Optional[int] = None
|
||||
bis_datum: Optional[str] = None
|
||||
schweregrad: Optional[str] = None
|
||||
reaktion: Optional[str] = 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)
|
||||
erinnerung: Optional[int] = None
|
||||
intervall_tage: Optional[int] = None
|
||||
tierarzt_id: Optional[int] = None
|
||||
deckdatum: Optional[str] = None
|
||||
wurftermin: Optional[str] = None
|
||||
deckdatum: Optional[str] = Field(None, max_length=32)
|
||||
wurftermin: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -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
|
||||
symptoms: str = Field(..., min_length=3, max_length=5000)
|
||||
|
||||
|
||||
@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
|
||||
police_nr: Optional[str] = None
|
||||
anbieter: str = Field(..., min_length=1, max_length=200)
|
||||
police_nr: Optional[str] = Field(None, max_length=100)
|
||||
jahresbeitrag: Optional[float] = None
|
||||
kontakt: Optional[str] = None
|
||||
ablaufdatum: Optional[str] = None
|
||||
notizen: Optional[str] = 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)
|
||||
|
||||
class InsuranceUpdate(BaseModel):
|
||||
anbieter: Optional[str] = None
|
||||
police_nr: Optional[str] = None
|
||||
anbieter: Optional[str] = Field(None, max_length=200)
|
||||
police_nr: Optional[str] = Field(None, max_length=100)
|
||||
jahresbeitrag: Optional[float] = None
|
||||
kontakt: Optional[str] = None
|
||||
ablaufdatum: Optional[str] = None
|
||||
notizen: Optional[str] = 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)
|
||||
|
||||
|
||||
@router.get("/{dog_id}/insurance")
|
||||
|
|
@ -674,12 +674,12 @@ TRIGGER_LABELS = {
|
|||
|
||||
|
||||
class BehaviorCreate(BaseModel):
|
||||
datum: str
|
||||
uhrzeit: Optional[str] = None
|
||||
kategorie: str
|
||||
intensitaet: int = 3
|
||||
trigger: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/{dog_id}/behavior")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Hilfe / FAQ Routes"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
frage: str
|
||||
antwort: str
|
||||
sort_order: int = 0
|
||||
aktiv: int = 1
|
||||
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
|
||||
|
||||
|
||||
class ArticleUpdate(BaseModel):
|
||||
kategorie: Optional[str] = None
|
||||
frage: Optional[str] = None
|
||||
antwort: Optional[str] = None
|
||||
kategorie: Optional[str] = Field(None, max_length=100)
|
||||
frage: Optional[str] = Field(None, max_length=500)
|
||||
antwort: Optional[str] = Field(None, max_length=10000)
|
||||
sort_order: Optional[int] = None
|
||||
aktiv: Optional[int] = None
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
quantity: float = 1.0
|
||||
description: str = Field(..., max_length=500)
|
||||
quantity: float = 1.0
|
||||
unit_price: float
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
recipient_name: str
|
||||
recipient_email: str
|
||||
recipient_address: Optional[str] = None
|
||||
recipient_name: str = Field(..., max_length=200)
|
||||
recipient_email: str = Field(..., max_length=254)
|
||||
recipient_address: Optional[str] = Field(None, max_length=500)
|
||||
items: List[InvoiceItem]
|
||||
discount_pct: Optional[float] = 0.0
|
||||
service_period: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
service_period: Optional[str] = Field(None, max_length=200)
|
||||
notes: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
|
||||
class PayBody(BaseModel):
|
||||
paid_at: str
|
||||
paid_at: str = Field(..., max_length=32)
|
||||
paid_amount: float
|
||||
notes: Optional[str] = None
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class CancelBody(BaseModel):
|
||||
reason: str
|
||||
reason: str = Field(..., min_length=3, max_length=1000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""BAN YARO — KI Routes"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
rasse: Optional[str] = None
|
||||
alter: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/training")
|
||||
|
|
@ -23,8 +23,6 @@ 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"
|
||||
|
|
@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
|||
# POST /ki/tierarzt — KI-Tierarztfragen
|
||||
# ------------------------------------------------------------------
|
||||
class TierarztRequest(BaseModel):
|
||||
symptom: str
|
||||
symptom: str = Field(..., min_length=5, max_length=1000)
|
||||
dog_id: Optional[int] = None
|
||||
dog_name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
dog_name: Optional[str] = Field(None, max_length=80)
|
||||
rasse: Optional[str] = Field(None, max_length=80)
|
||||
|
||||
|
||||
@router.post("/tierarzt")
|
||||
|
|
@ -81,8 +79,6 @@ 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:
|
||||
|
|
@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int):
|
|||
|
||||
class BirthdayRequest(BaseModel):
|
||||
dog_id: int
|
||||
name: str
|
||||
rasse: Optional[str] = None
|
||||
name: str = Field(..., max_length=80)
|
||||
rasse: Optional[str] = Field(None, max_length=80)
|
||||
alter: Optional[int] = None
|
||||
mode: str = "tomorrow" # "tomorrow" | "today"
|
||||
mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today"
|
||||
|
||||
@router.post("/geburtstag")
|
||||
async def ki_geburtstag(req: BirthdayRequest, request: Request,
|
||||
|
|
@ -368,12 +364,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
|||
# ------------------------------------------------------------------
|
||||
class AbschiedRequest(BaseModel):
|
||||
dog_id: int
|
||||
name: str
|
||||
rasse: Optional[str] = None
|
||||
name: str = Field(..., max_length=80)
|
||||
rasse: Optional[str] = Field(None, max_length=80)
|
||||
km_total: Optional[float] = None
|
||||
diary_count: Optional[int] = None
|
||||
gemeinsam_tage: Optional[int] = None
|
||||
last_entry_titel: Optional[str] = None
|
||||
last_entry_titel: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
@router.post("/abschied")
|
||||
async def ki_abschied(req: AbschiedRequest, request: Request,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Hunde-Knigge Routes"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
answer: str
|
||||
szenario_id: str = Field(..., max_length=100)
|
||||
answer: str = Field(..., max_length=100)
|
||||
|
||||
|
||||
class KiRatRequest(BaseModel):
|
||||
situation: str
|
||||
situation: str = Field(..., min_length=3, max_length=2000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
ende: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
beginn: str = Field(..., max_length=32)
|
||||
ende: Optional[str] = Field(None, max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
class LaeufiUpdate(BaseModel):
|
||||
beginn: Optional[str] = None
|
||||
ende: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
beginn: Optional[str] = Field(None, max_length=32)
|
||||
ende: Optional[str] = Field(None, max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
class ProgestCreate(BaseModel):
|
||||
datum: str
|
||||
datum: str = Field(..., max_length=32)
|
||||
wert: Optional[float] = None
|
||||
einheit: str = "ng/ml"
|
||||
labor: Optional[str] = None
|
||||
notiz: Optional[str] = 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)
|
||||
|
||||
class ProgestUpdate(BaseModel):
|
||||
datum: Optional[str] = None
|
||||
datum: Optional[str] = Field(None, max_length=32)
|
||||
wert: Optional[float] = None
|
||||
einheit: Optional[str] = None
|
||||
labor: Optional[str] = None
|
||||
notiz: Optional[str] = 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)
|
||||
|
||||
class DeckCreate(BaseModel):
|
||||
deckdatum: str
|
||||
deckdatum: str = Field(..., max_length=32)
|
||||
laeufi_id: Optional[int] = None
|
||||
ruede_id: Optional[int] = None
|
||||
ruede_name: Optional[str] = None
|
||||
deckart: str = "natuerlich"
|
||||
ruede_name: Optional[str] = Field(None, max_length=200)
|
||||
deckart: str = Field("natuerlich", max_length=50)
|
||||
traechtig: int = 0
|
||||
ultraschall_datum: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
ultraschall_datum: Optional[str] = Field(None, max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
class DeckUpdate(BaseModel):
|
||||
deckdatum: Optional[str] = None
|
||||
deckdatum: Optional[str] = Field(None, max_length=32)
|
||||
ruede_id: Optional[int] = None
|
||||
ruede_name: Optional[str] = None
|
||||
deckart: Optional[str] = None
|
||||
ruede_name: Optional[str] = Field(None, max_length=200)
|
||||
deckart: Optional[str] = Field(None, max_length=50)
|
||||
traechtig: Optional[int] = None
|
||||
ultraschall_datum: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
ultraschall_datum: Optional[str] = Field(None, max_length=32)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None # A, B, C …
|
||||
wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf"
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
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)
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
geburt_datum: Optional[str] = None
|
||||
erwartetes_datum: Optional[str] = None
|
||||
geburt_datum: Optional[str] = Field(None, max_length=32)
|
||||
erwartetes_datum: Optional[str] = Field(None, max_length=32)
|
||||
welpen_gesamt: Optional[int] = None
|
||||
welpen_verfuegbar: Optional[int] = None
|
||||
beschreibung: Optional[str] = None
|
||||
gesundheitstests: Optional[str] = None
|
||||
preis_spanne: Optional[str] = None
|
||||
status: str = "geplant"
|
||||
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)
|
||||
sichtbar: int = 0
|
||||
sichtbar_bis: Optional[str] = None
|
||||
sichtbar_bis: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
|
||||
class LitterUpdate(BaseModel):
|
||||
wurf_rang: Optional[str] = None
|
||||
wurf_name: Optional[str] = None
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
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)
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
geburt_datum: Optional[str] = None
|
||||
erwartetes_datum: Optional[str] = None
|
||||
geburt_datum: Optional[str] = Field(None, max_length=32)
|
||||
erwartetes_datum: Optional[str] = Field(None, max_length=32)
|
||||
welpen_gesamt: Optional[int] = None
|
||||
welpen_verfuegbar: Optional[int] = None
|
||||
beschreibung: Optional[str] = None
|
||||
gesundheitstests: Optional[str] = None
|
||||
preis_spanne: Optional[str] = None
|
||||
status: Optional[str] = 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)
|
||||
sichtbar: Optional[int] = None
|
||||
sichtbar_bis: Optional[str] = None
|
||||
sichtbar_bis: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
|
||||
class PuppyCreate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
geschlecht: Optional[str] = None # maennlich|weiblich
|
||||
farbe: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
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)
|
||||
geburtsgewicht: Optional[float] = None # Gramm
|
||||
status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben
|
||||
status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben
|
||||
status_sichtbar: int = 1
|
||||
notiz: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class PuppyUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
farbe: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
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)
|
||||
geburtsgewicht: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
status: Optional[str] = Field(None, max_length=30)
|
||||
status_sichtbar: Optional[int] = None
|
||||
notiz: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class WeightEntry(BaseModel):
|
||||
gewicht_g: float
|
||||
gemessen_am: str # YYYY-MM-DD
|
||||
gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -663,15 +663,15 @@ async def generate_contract(
|
|||
# Warteliste
|
||||
# ------------------------------------------------------------------
|
||||
class WaitlistEntry(BaseModel):
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
telefon: Optional[str] = None
|
||||
nachricht: Optional[str] = None
|
||||
wunsch_geschlecht: str = "egal"
|
||||
wunsch_farbe: Optional[str] = None
|
||||
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)
|
||||
prioritaet: int = 0
|
||||
status: str = "anfrage"
|
||||
notiz: Optional[str] = None
|
||||
status: str = Field("anfrage", max_length=30)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class WaitlistUpdate(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,44 +1,32 @@
|
|||
"""BAN YARO — Verlorener Hund Routes"""
|
||||
|
||||
import os, uuid, math
|
||||
import os, uuid
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
rasse: Optional[str] = None
|
||||
beschreibung: str
|
||||
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)
|
||||
lat: float
|
||||
lon: float
|
||||
dog_id: Optional[int] = None
|
||||
client_time: Optional[str] = None
|
||||
client_time: Optional[str] = Field(None, max_length=64)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -60,7 +48,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(lat, lon, entry["lat"], entry["lon"])
|
||||
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
|
||||
if dist > radius_km * 1000:
|
||||
continue
|
||||
entry["distanz_m"] = round(dist)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Hunde-Filme Routes"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
titel: str
|
||||
originaltitel: Optional[str] = None
|
||||
id: str = Field(..., max_length=100)
|
||||
titel: str = Field(..., min_length=1, max_length=200)
|
||||
originaltitel: Optional[str] = Field(None, max_length=200)
|
||||
jahr: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
typ: str = "film"
|
||||
hund_rasse: Optional[str] = None
|
||||
stirbt_der_hund: bool = False
|
||||
beschreibung: Optional[str] = None
|
||||
bild_emoji: str = "🐾"
|
||||
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)
|
||||
imdb_rating: Optional[float] = None
|
||||
streaming: Optional[str] = None
|
||||
streaming: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
class MovieUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
originaltitel: Optional[str] = None
|
||||
titel: Optional[str] = Field(None, max_length=200)
|
||||
originaltitel: Optional[str] = Field(None, max_length=200)
|
||||
jahr: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
typ: Optional[str] = None
|
||||
hund_rasse: Optional[str] = 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)
|
||||
stirbt_der_hund: Optional[bool] = None
|
||||
beschreibung: Optional[str] = None
|
||||
bild_emoji: Optional[str] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
bild_emoji: Optional[str] = Field(None, max_length=10)
|
||||
imdb_rating: Optional[float] = None
|
||||
streaming: Optional[str] = None
|
||||
streaming: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
meta_json: Optional[Any] = None
|
||||
location_name: Optional[str] = None
|
||||
parent_label: Optional[str] = None
|
||||
client_time: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
text: Optional[str] = None
|
||||
text: Optional[str] = Field(None, max_length=5000)
|
||||
meta_json: Optional[Any] = None
|
||||
location_name: Optional[str] = None
|
||||
parent_label: Optional[str] = None
|
||||
location_name: Optional[str] = Field(None, max_length=300)
|
||||
parent_label: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import httpx
|
|||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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 Exception("Alle Overpass-Instanzen fehlgeschlagen")
|
||||
raise HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.")
|
||||
|
||||
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
|
||||
type: str = Field(..., max_length=200)
|
||||
lat: float
|
||||
lon: float
|
||||
name: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
name: Optional[str] = Field(None, max_length=300)
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
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
|
||||
grund: str
|
||||
type: str = Field(..., max_length=100)
|
||||
grund: str = Field(..., max_length=200)
|
||||
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: str = 'opening_hours'
|
||||
new_value: str
|
||||
poi_name: str = Field(..., max_length=300)
|
||||
field: str = Field('opening_hours', max_length=50)
|
||||
new_value: str = Field(..., max_length=1000)
|
||||
|
||||
|
||||
@router.post('/pois/{osm_id}/edit', status_code=201)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from typing import List, Optional
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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
|
||||
label: str
|
||||
subject: str
|
||||
body: str
|
||||
from_account: str = "partner"
|
||||
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)
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
label: str
|
||||
subject: str
|
||||
body: str
|
||||
from_account: str = "partner"
|
||||
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)
|
||||
|
||||
|
||||
class SendRequest(BaseModel):
|
||||
to: List[str]
|
||||
subject: str
|
||||
body: str
|
||||
from_account: str = "partner"
|
||||
template_id: Optional[int] = None
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from database import db
|
||||
from auth import require_admin, get_current_user
|
||||
|
||||
|
|
@ -10,8 +10,8 @@ router = APIRouter()
|
|||
|
||||
|
||||
class PartnerCodeCreate(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
label: str = Field(..., min_length=1, max_length=200)
|
||||
grants_founder: int = 1
|
||||
max_uses: Optional[int] = None
|
||||
|
||||
|
|
@ -93,21 +93,34 @@ 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"]:
|
||||
# Neue Gründer-Nummer zuweisen
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
).fetchone()[0]
|
||||
if total >= FOUNDER_MAX:
|
||||
# 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:
|
||||
raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.")
|
||||
updates["founder_number"] = total + 1
|
||||
# is_founder + founder_number sind atomar gesetzt — aus updates entfernen
|
||||
updates.pop("is_founder", None)
|
||||
updates.pop("founder_number", None)
|
||||
elif updates.get("is_founder") == 0:
|
||||
# Gründer-Status entfernen → founder_number ebenfalls leeren
|
||||
updates["founder_number"] = None
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(
|
||||
f"UPDATE users SET {set_clause} WHERE id=?",
|
||||
(*updates.values(), user_id)
|
||||
)
|
||||
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)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None
|
||||
allergien: Optional[str] = None
|
||||
besonderheiten: Optional[str] = None
|
||||
blutgruppe: Optional[str] = Field(None, max_length=50)
|
||||
allergien: Optional[str] = Field(None, max_length=2000)
|
||||
besonderheiten: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class VaccinationCreate(BaseModel):
|
||||
krankheit: str
|
||||
datum: str
|
||||
naechste: Optional[str] = None
|
||||
tierarzt: Optional[str] = None
|
||||
charge_nr: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
class MedicationCreate(BaseModel):
|
||||
name: str
|
||||
dosierung: Optional[str] = None
|
||||
von: Optional[str] = None
|
||||
bis: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,50 +1,40 @@
|
|||
"""BAN YARO — Hundefreundliche Orte"""
|
||||
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from auth import get_current_user, require_owner
|
||||
from math_utils import haversine_m
|
||||
|
||||
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
|
||||
typ: str
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
typ: str = Field(..., max_length=50)
|
||||
lat: float
|
||||
lon: float
|
||||
adresse: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
telefon: Optional[str] = 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)
|
||||
hund_rein: Optional[bool] = None
|
||||
leine_pflicht: Optional[bool] = None
|
||||
wasser_fuer_hunde: Optional[bool] = None
|
||||
|
||||
class PlaceUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
typ: Optional[str] = None
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
typ: Optional[str] = Field(None, max_length=50)
|
||||
lat: Optional[float]= None
|
||||
lon: Optional[float]= None
|
||||
adresse: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
telefon: Optional[str] = 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)
|
||||
hund_rein: Optional[bool] = None
|
||||
leine_pflicht: Optional[bool] = None
|
||||
wasser_fuer_hunde: Optional[bool] = None
|
||||
|
|
@ -79,7 +69,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(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -131,11 +121,10 @@ 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 = 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.")
|
||||
row = require_owner(
|
||||
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
|
||||
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
|
||||
)
|
||||
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
if not updates:
|
||||
|
|
@ -160,9 +149,8 @@ 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:
|
||||
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.")
|
||||
require_owner(
|
||||
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
|
||||
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
|
||||
)
|
||||
conn.execute("DELETE FROM places WHERE id = ?", (place_id,))
|
||||
|
|
|
|||
|
|
@ -1,30 +1,17 @@
|
|||
"""BAN YARO — Playdate-Matching"""
|
||||
|
||||
import math
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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:
|
||||
|
|
@ -53,18 +40,18 @@ class ListingUpsert(BaseModel):
|
|||
dog_id: int
|
||||
lat: float
|
||||
lon: float
|
||||
ort_name: Optional[str] = None
|
||||
ort_name: Optional[str] = Field(None, max_length=300)
|
||||
radius_km: int = 10
|
||||
beschreibung: Optional[str] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class RequestCreate(BaseModel):
|
||||
to_dog_id: int
|
||||
nachricht: Optional[str] = None
|
||||
nachricht: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class RequestPatch(BaseModel):
|
||||
status: str # accepted | declined
|
||||
status: str = Field(..., max_length=30) # accepted | declined
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -107,7 +94,7 @@ async def nearby(lat: float, lon: float, radius: int = 10,
|
|||
|
||||
result = []
|
||||
for r in rows:
|
||||
dist = _haversine(lat, lon, r["lat"], r["lon"])
|
||||
dist = haversine_km(lat, lon, r["lat"], r["lon"])
|
||||
if dist <= radius:
|
||||
result.append({
|
||||
"listing_id": r["listing_id"],
|
||||
|
|
|
|||
|
|
@ -1,45 +1,33 @@
|
|||
"""BAN YARO — Giftköder-Alarm Routes"""
|
||||
|
||||
import os, uuid, math
|
||||
import os, uuid
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None
|
||||
typ: str = "unbekannt"
|
||||
beschreibung: Optional[str] = Field(None, max_length=2000)
|
||||
typ: str = Field("unbekannt", max_length=50)
|
||||
|
||||
|
||||
class PoisonResolve(BaseModel):
|
||||
grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
|
||||
grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -62,7 +50,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000):
|
|||
results = []
|
||||
for r in rows:
|
||||
entry = dict(r)
|
||||
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
|
||||
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
|
||||
if dist <= radius:
|
||||
entry["distanz_m"] = round(dist)
|
||||
results.append(entry)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import uuid
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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] = None
|
||||
bio: Optional[str] = None
|
||||
wohnort: Optional[str] = None
|
||||
erfahrung: Optional[str] = None
|
||||
social_link: Optional[str] = None
|
||||
profil_sichtbarkeit: Optional[str] = None
|
||||
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)
|
||||
notes_ki_enabled: Optional[int] = None
|
||||
gassi_stunde_push: Optional[int] = None
|
||||
preferred_theme: Optional[str] = None
|
||||
billing_address: Optional[str] = None
|
||||
geburtstag: Optional[str] = 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)
|
||||
|
||||
|
||||
def _load_user(user_id: int) -> dict:
|
||||
|
|
@ -61,12 +61,7 @@ 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.")
|
||||
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.")
|
||||
# Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt.
|
||||
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).")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import os
|
|||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
endpoint: str = Field(..., max_length=2000)
|
||||
keys: dict # { p256dh, auth }
|
||||
expirationTime: Optional[int] = None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Bewertungssystem (Ratings)"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
target_type: str = Field(..., max_length=50)
|
||||
target_id: int
|
||||
stars: int
|
||||
kommentar: Optional[str] = None
|
||||
kommentar: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -49,12 +49,27 @@ 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."""
|
||||
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.
|
||||
|
||||
Hinweis: Die EU hat die API mehrfach umgezogen — wenn der Endpoint
|
||||
404 oder andere persistent fehler liefert, geben wir [] zurück und
|
||||
loggen nur als Warning (nicht Error), damit das Error-Digest nicht
|
||||
täglich spammt.
|
||||
"""
|
||||
try:
|
||||
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 []
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""BAN YARO — Gassi-Routen"""
|
||||
|
||||
import datetime as _dt
|
||||
import json, math, os, uuid
|
||||
import json, os, uuid
|
||||
import httpx
|
||||
import polyline as _polyline
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
|
|
@ -13,6 +13,7 @@ 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()
|
||||
|
||||
|
|
@ -27,16 +28,6 @@ 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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -46,29 +37,29 @@ class GPSPoint(BaseModel):
|
|||
alt: Optional[float] = None
|
||||
|
||||
class RouteCreate(BaseModel):
|
||||
name: str
|
||||
beschreibung: Optional[str] = None
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
gps_track: List[GPSPoint]
|
||||
distanz_km: Optional[float] = None
|
||||
dauer_min: Optional[int] = None
|
||||
schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll
|
||||
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
|
||||
schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll
|
||||
untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix
|
||||
schatten: Optional[bool] = None
|
||||
leine_empfohlen: Optional[bool] = None
|
||||
is_public: Optional[bool] = False
|
||||
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
||||
client_time: Optional[str] = None
|
||||
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium
|
||||
client_time: Optional[str] = Field(None, max_length=64)
|
||||
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
|
||||
|
||||
class RouteUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
beschreibung: Optional[str] = None
|
||||
schwierigkeit: Optional[str] = None
|
||||
untergrund: Optional[str] = None
|
||||
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)
|
||||
schatten: Optional[bool] = None
|
||||
leine_empfohlen: Optional[bool] = None
|
||||
is_public: Optional[bool] = None
|
||||
hunde_tauglichkeit: Optional[str] = None
|
||||
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
class RouteDogs(BaseModel):
|
||||
dog_ids: List[int]
|
||||
|
|
@ -137,7 +128,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(lat, lon, r['start_lat'], r['start_lon']) <= radius
|
||||
if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius
|
||||
]
|
||||
|
||||
user_id = user['id'] if user else None
|
||||
|
|
@ -429,10 +420,7 @@ 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]
|
||||
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 += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon'])
|
||||
new_km = round(new_km, 2)
|
||||
|
||||
# Dauer proportional schätzen (Original-Pace)
|
||||
|
|
@ -565,7 +553,7 @@ async def add_route_photo(
|
|||
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
|
||||
# ------------------------------------------------------------------
|
||||
class RouteFeedback(BaseModel):
|
||||
text: str
|
||||
text: str = Field(..., min_length=5, max_length=2000)
|
||||
|
||||
@router.post("/{route_id}/feedback", status_code=201)
|
||||
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):
|
||||
|
|
|
|||
|
|
@ -1,33 +1,23 @@
|
|||
"""BAN YARO — Service-Angebote (Sitting & Walks Matching)"""
|
||||
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
beschreibung: Optional[str] = None
|
||||
type: str = Field(..., max_length=30)
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
preis_pro_tag: Optional[float] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
|
|
@ -60,7 +50,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(lat, lon, d['lat'], d['lon'])
|
||||
dist = haversine_km(lat, lon, d['lat'], d['lon'])
|
||||
if dist > radius:
|
||||
continue
|
||||
d['distanz_km'] = round(dist, 1)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import secrets
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ share_router = APIRouter()
|
|||
|
||||
|
||||
class ShareInvite(BaseModel):
|
||||
role: str = "editor" # viewer | editor
|
||||
role: str = Field("editor", max_length=20) # viewer | editor
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,32 +1,23 @@
|
|||
"""BAN YARO — Hundesitting"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
preis_pro_tag: float = 0
|
||||
max_hunde: int = 1
|
||||
lat: Optional[float] = None
|
||||
|
|
@ -35,7 +26,7 @@ class SitterCreate(BaseModel):
|
|||
services: List[str] = []
|
||||
|
||||
class SitterUpdate(BaseModel):
|
||||
beschreibung: Optional[str] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
preis_pro_tag: Optional[float] = None
|
||||
max_hunde: Optional[int] = None
|
||||
lat: Optional[float] = None
|
||||
|
|
@ -47,12 +38,12 @@ class SitterUpdate(BaseModel):
|
|||
class RequestCreate(BaseModel):
|
||||
sitter_id: int
|
||||
dog_ids: List[int] = []
|
||||
von: str # YYYY-MM-DD
|
||||
bis: str
|
||||
nachricht: Optional[str] = None
|
||||
von: str = Field(..., max_length=32) # YYYY-MM-DD
|
||||
bis: str = Field(..., max_length=32)
|
||||
nachricht: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
class RequestUpdate(BaseModel):
|
||||
status: str # angenommen | abgelehnt | abgebrochen
|
||||
status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -80,7 +71,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(lat, lon, d['lat'], d['lon'])
|
||||
dist = haversine_m(lat, lon, d['lat'], d['lon'])
|
||||
if dist > radius:
|
||||
continue
|
||||
d['distanz_m'] = round(dist)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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 # 'YYYY-MM-DD'
|
||||
valid_until: str = Field(..., max_length=32) # 'YYYY-MM-DD'
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import random
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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 = "both"
|
||||
format: str = "post"
|
||||
topic: str
|
||||
platform: str = Field("both", max_length=30)
|
||||
format: str = Field("post", max_length=30)
|
||||
topic: str = Field(..., min_length=2, max_length=500)
|
||||
breed_id: Optional[int] = None
|
||||
|
||||
|
||||
class EvaluateRequest(BaseModel):
|
||||
platform: str = "instagram"
|
||||
format: str = "post"
|
||||
draft: str
|
||||
platform: str = Field("instagram", max_length=30)
|
||||
format: str = Field("post", max_length=30)
|
||||
draft: str = Field(..., min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
scheduled_at: Optional[str] = None
|
||||
published_at: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
post_url: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
||||
def _used_topics(limit: int = 30) -> str:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
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
|
||||
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)
|
||||
ist_notfallpraxis: bool = False
|
||||
opening_hours: Optional[str] = None
|
||||
opening_hours: Optional[str] = Field(None, max_length=500)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
osm_id: Optional[str] = None
|
||||
osm_id: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
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] = None
|
||||
text: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
|
||||
class TierarztUpdate(BaseModel):
|
||||
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
|
||||
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)
|
||||
ist_notfallpraxis: Optional[bool] = None
|
||||
aktiv: Optional[bool] = None
|
||||
opening_hours: Optional[str] = None
|
||||
opening_hours: Optional[str] = Field(None, max_length=500)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
osm_id: Optional[str] = None
|
||||
osm_id: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
def _fmt_opening_hours(raw: str | None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""BAN YARO — Übungs- & Trainingsfortschritt"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None
|
||||
schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]'
|
||||
tipp: Optional[str] = None
|
||||
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)
|
||||
|
||||
@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
|
||||
status: Optional[str] = None
|
||||
dog_id: Optional[int] = None
|
||||
exercise_id: str = Field(..., max_length=200)
|
||||
status: Optional[str] = Field(None, max_length=50)
|
||||
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
|
||||
checked: bool
|
||||
dog_id: Optional[int] = None
|
||||
item_key: str = Field(..., max_length=200)
|
||||
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
|
||||
exercise_name: str
|
||||
datum: Optional[str] = None
|
||||
wiederholungen: int = 1
|
||||
erfolgsquote: int = 50
|
||||
hund_stimmung: Optional[str] = "aufmerksam"
|
||||
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)
|
||||
zufriedenheit: Optional[int] = 3
|
||||
notiz: Optional[str] = None
|
||||
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
|
||||
notiz: Optional[str] = Field(None, max_length=2000)
|
||||
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
|
|
|
|||
|
|
@ -1,55 +1,43 @@
|
|||
"""BAN YARO — Gassi-Treffen"""
|
||||
|
||||
import math, os, uuid
|
||||
import os, uuid
|
||||
import httpx
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
datum: str # YYYY-MM-DD
|
||||
uhrzeit: str # HH:MM
|
||||
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
|
||||
lat: float
|
||||
lon: float
|
||||
ort_name: Optional[str] = None
|
||||
ort_name: Optional[str] = Field(None, max_length=300)
|
||||
max_teilnehmer: int = 10
|
||||
beschreibung: Optional[str] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
class WalkUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
datum: Optional[str] = None
|
||||
uhrzeit: Optional[str] = None
|
||||
titel: Optional[str] = Field(None, max_length=200)
|
||||
datum: Optional[str] = Field(None, max_length=32)
|
||||
uhrzeit: Optional[str] = Field(None, max_length=20)
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
ort_name: Optional[str] = None
|
||||
ort_name: Optional[str] = Field(None, max_length=300)
|
||||
max_teilnehmer: Optional[int] = None
|
||||
beschreibung: Optional[str] = None
|
||||
beschreibung: Optional[str] = Field(None, max_length=5000)
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
|
||||
|
|
@ -58,7 +46,7 @@ class InviteRequest(BaseModel):
|
|||
friend_id: int
|
||||
|
||||
class RsvpRequest(BaseModel):
|
||||
status: str # 'yes' | 'maybe' | 'no'
|
||||
status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -91,7 +79,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(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -131,7 +119,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"],
|
||||
|
|
@ -142,7 +130,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"],
|
||||
|
|
@ -170,7 +158,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
titel: str
|
||||
text: str
|
||||
rasse: str = Field(..., max_length=100)
|
||||
titel: str = Field(..., min_length=3, max_length=200)
|
||||
text: str = Field(..., min_length=10, max_length=10000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -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 # "approve" | "reject"
|
||||
reject_reason: str = ""
|
||||
action: str = Field(..., max_length=30) # "approve" | "reject"
|
||||
reject_reason: str = Field("", max_length=2000)
|
||||
|
||||
|
||||
@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 # "hat" oder "will"
|
||||
typ: str = Field(..., max_length=30) # "hat" oder "will"
|
||||
|
||||
class ZuchterCreate(BaseModel):
|
||||
rasse_slug: str
|
||||
name: str
|
||||
zwingername: str = ""
|
||||
ort: str = ""
|
||||
plz: str = ""
|
||||
bundesland: str = ""
|
||||
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)
|
||||
vdh_mitglied: int = 0
|
||||
website: str = ""
|
||||
telefon: str = ""
|
||||
beschreibung: str = ""
|
||||
website: str = Field("", max_length=500)
|
||||
telefon: str = Field("", max_length=30)
|
||||
beschreibung: str = Field("", max_length=10000)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
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
|
||||
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)
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
zuechter_name: Optional[str] = None
|
||||
eigentuemer_name: Optional[str] = None
|
||||
zuechter_name: Optional[str] = Field(None, max_length=200)
|
||||
eigentuemer_name: Optional[str] = Field(None, max_length=200)
|
||||
is_public: int = 1
|
||||
notiz: Optional[str] = None
|
||||
foto_url: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=5000)
|
||||
foto_url: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class HundUpdate(BaseModel):
|
||||
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
|
||||
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)
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
zuechter_name: Optional[str] = None
|
||||
eigentuemer_name: Optional[str] = None
|
||||
zuechter_name: Optional[str] = Field(None, max_length=200)
|
||||
eigentuemer_name: Optional[str] = Field(None, max_length=200)
|
||||
is_public: Optional[int] = None
|
||||
notiz: Optional[str] = None
|
||||
foto_url: Optional[str] = None
|
||||
notiz: Optional[str] = Field(None, max_length=5000)
|
||||
foto_url: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class HealthTestCreate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class HealthTestUpdate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
class GeneticTestCreate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class GeneticTestUpdate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
class TitelCreate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class TitelUpdate(BaseModel):
|
||||
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
|
||||
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)
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import logging
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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] = None
|
||||
welfare_level: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class HundBeschreibungBody(BaseModel):
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ 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
|
||||
|
|
@ -1832,11 +1840,13 @@ 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, d.user_id, d.dog_id,
|
||||
SELECT d.id, d.titel, d.datum, dogs.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
|
||||
|
|
@ -2231,3 +2241,16 @@ 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))
|
||||
|
|
|
|||
|
|
@ -235,6 +235,45 @@
|
|||
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
|
||||
------------------------------------------------------------ */
|
||||
|
|
@ -8905,3 +8944,44 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
/* Text — Warmbraun aus dem Halsband */
|
||||
--c-text: #2A1F14;
|
||||
--c-text-secondary: #7A6A58;
|
||||
--c-text-muted: #B0A090;
|
||||
--c-text-muted: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */
|
||||
--c-text-inverse: #FAF7F2;
|
||||
|
||||
/* Funktionsfarben */
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
|
||||
--c-text: #F0EAE0;
|
||||
--c-text-secondary: #C0B0A0;
|
||||
--c-text-muted: #806A58;
|
||||
--c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */
|
||||
--c-text-inverse: #2A1F14;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--c-text-secondary);
|
||||
cursor: pointer;
|
||||
|
|
@ -99,8 +99,8 @@
|
|||
|
||||
/* Hamburger-Button (nur Mobile) */
|
||||
.header-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
328
backend/static/css/lists.css
Normal file
328
backend/static/css/lists.css
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/* ============================================================
|
||||
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);
|
||||
}
|
||||
65
backend/static/css/utilities.css
Normal file
65
backend/static/css/utilities.css
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/* ============================================================
|
||||
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);
|
||||
}
|
||||
|
|
@ -86,24 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<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>
|
||||
<script src="/js/boot-early.js?v=1120"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<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">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -111,7 +101,8 @@
|
|||
<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:7px 16px;align-items:center;justify-content:center;gap:8px;
|
||||
padding:calc(env(safe-area-inset-top, 0px) + 7px) 16px 7px;
|
||||
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"/>
|
||||
|
|
@ -125,7 +116,8 @@
|
|||
<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:8px 16px;align-items:center;justify-content:center;gap:10px;
|
||||
padding:calc(env(safe-area-inset-top, 0px) + 8px) 16px 8px;
|
||||
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"/>
|
||||
|
|
@ -326,7 +318,7 @@
|
|||
</div>
|
||||
<div id="header-actions"></div>
|
||||
<button id="header-user-btn" aria-label="Profil"
|
||||
style="width:36px;height:36px;border-radius:50%;border:2px solid var(--c-border);
|
||||
style="width:44px;height:44px;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">
|
||||
|
|
@ -625,11 +617,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -637,130 +629,9 @@
|
|||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1120"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1099'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1120'; // ← 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,16 +129,23 @@ 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;
|
||||
if (!modalOpen) {
|
||||
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) {
|
||||
window._byUpdatePending = false;
|
||||
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
|
||||
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
|
||||
sessionStorage.setItem('by_update_target', pageId);
|
||||
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {}
|
||||
location.href = '/force-update';
|
||||
return;
|
||||
}
|
||||
// Modal offen → beim nächsten Seitenwechsel versuchen
|
||||
// Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen
|
||||
}
|
||||
if (window.Worlds?._visible) window.Worlds.hide();
|
||||
|
||||
|
|
|
|||
13
backend/static/js/boot-early.js
Normal file
13
backend/static/js/boot-early.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/* 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');
|
||||
})();
|
||||
127
backend/static/js/boot.js
Normal file
127
backend/static/js/boot.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/* ============================================================
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
99
backend/static/js/landing-init.js
Normal file
99
backend/static/js/landing-init.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* ============================================================
|
||||
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() {});
|
||||
});
|
||||
|
|
@ -234,6 +234,42 @@ 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']
|
||||
|
|
@ -305,7 +341,8 @@ window.OfflineIndicator = (() => {
|
|||
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
|
||||
});
|
||||
}
|
||||
setInterval(() => { _prefetchData(); refresh(); }, 60_000);
|
||||
_checkStorageQuota(); // beim Init prüfen
|
||||
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
|
||||
}
|
||||
|
||||
return { init, refresh, openStatus };
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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="${_esc(_rasseFilter)}">
|
||||
value="${UI.escape(_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 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
|
||||
<h3 class="mb-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>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
|
||||
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${UI.escape(_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" style="font-size:var(--text-sm)">
|
||||
class="btn btn-secondary 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="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
|
||||
? `<img src="${UI.escape(a.foto_url)}" alt="${UI.escape(a.name)}"
|
||||
style="width:100%;height:100%;object-fit:cover"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</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="${_esc(a.adoptions_url)}"
|
||||
<div data-adp-url="${UI.escape(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">
|
||||
${_esc(a.name)}
|
||||
${UI.escape(a.name)}
|
||||
</div>
|
||||
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(rasseTxt)}
|
||||
${UI.escape(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)">
|
||||
${_esc(alterTxt)}
|
||||
${UI.escape(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)">
|
||||
${_esc(distTxt)}
|
||||
${UI.escape(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="${_esc(tierheim)}">
|
||||
${UI.icon('house-line')} ${_esc(tierheim)}
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${UI.escape(tierheim)}">
|
||||
${UI.icon('house-line')} ${UI.escape(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 style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="flex-col-gap-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" style="font-size:var(--text-sm)">
|
||||
class="btn btn-secondary btn-sm 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" style="font-size:var(--text-sm)">
|
||||
class="btn btn-secondary btn-sm text-sm">
|
||||
${UI.icon('magnifying-glass')} tierschutz.com
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -459,7 +459,7 @@ window.Page_adoption = (() => {
|
|||
|
||||
function _shelterRow(s) {
|
||||
return `
|
||||
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
|
||||
<a href="${UI.escape(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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(s.name)}
|
||||
${UI.escape(s.name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${_esc(s.plz)} ${_esc(s.stadt)}
|
||||
<div class="text-xs-secondary">
|
||||
${UI.escape(s.plz)} ${UI.escape(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 style="margin-bottom:var(--space-2)">Noch keine Hunde zur Weitervermittlung</h3>
|
||||
<h3 class="mb-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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<p class="text-sm-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 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<h4 class="mb-3">Meine Inserate</h4>
|
||||
<div class="flex-col-gap-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="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
|
||||
? `<img src="${UI.escape(l.foto_url)}" alt="${UI.escape(l.name)}"
|
||||
style="width:100%;height:100%;object-fit:cover"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>'">`
|
||||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
|
||||
|
|
@ -635,11 +635,11 @@ window.Page_adoption = (() => {
|
|||
|
||||
const interestBtn = l.user_interested
|
||||
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
|
||||
data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
|
||||
data-adp-interest="${UI.escape(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="${_esc(l.id)}" data-adp-interested="false"
|
||||
data-adp-interest="${UI.escape(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">
|
||||
${_esc(statusLabel)}
|
||||
${UI.escape(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">
|
||||
${_esc(l.name)}
|
||||
${UI.escape(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">
|
||||
${_esc(l.rasse)}
|
||||
${UI.escape(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)">
|
||||
${_esc(alterLabel)}
|
||||
${UI.escape(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)">
|
||||
${_esc(distTxt)}
|
||||
${UI.escape(distTxt)}
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
|
||||
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${UI.escape(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">
|
||||
${_esc(l.beschreibung)}
|
||||
${UI.escape(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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(l.name)}
|
||||
${UI.escape(l.name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-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="${_esc(l.id)}">
|
||||
data-adp-status-change="${UI.escape(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="${_esc(l.id)}">
|
||||
data-adp-delete="${UI.escape(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" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<form id="adp-interest-form" class="flex-col-gap-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" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<form id="adp-create-form" class="flex-col-gap-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
|
||||
<label class="form-label">Name <span class="text-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="${_esc(_lat ? '' : '')}">
|
||||
placeholder="z.B. 80331" value="${UI.escape(_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 style="color:var(--c-danger)">*</span></label>
|
||||
<label class="form-label">Beschreibung <span class="text-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" style="width:100%" id="adp-create-submit">
|
||||
<button type="submit" form="adp-create-form" class="btn btn-primary w-full" id="adp-create-submit">
|
||||
${UI.icon('plus')} Inserat erstellen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
|
|
@ -941,15 +941,6 @@ window.Page_adoption = (() => {
|
|||
return 'Senior';
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC API
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
318
backend/static/js/pages/breeder-editor.js
Normal file
318
backend/static/js/pages/breeder-editor.js
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/* ============================================================
|
||||
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 1–2 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 1–2 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 };
|
||||
|
||||
})();
|
||||
|
|
@ -7,8 +7,6 @@ window.Page_breeder = (() => {
|
|||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
|
|
@ -51,7 +49,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')} ${_esc(e.message || 'Züchter nicht gefunden.')}
|
||||
${UI.icon('magnifying-glass')} ${UI.escape(e.message || 'Züchter nicht gefunden.')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -75,22 +73,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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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">
|
||||
${_esc(p.zwingername)}
|
||||
${UI.escape(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">${_esc(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">${UI.escape(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')} ${_esc(p.stadt)}</span>` : ''}
|
||||
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${_esc(seit)}</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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${p.logo_url
|
||||
? `<img src="${_esc(p.logo_url)}" alt="Zwinger-Logo"
|
||||
? `<img src="${UI.escape(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'">`
|
||||
|
|
@ -117,7 +115,7 @@ window.Page_breeder = (() => {
|
|||
Anmelden um zu schreiben
|
||||
</button>`
|
||||
}
|
||||
${p.website ? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
|
||||
${p.website ? `<a href="${UI.escape(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;
|
||||
|
|
@ -134,7 +132,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">${_esc(p.beschreibung)}</p>
|
||||
<p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${UI.escape(p.beschreibung)}</p>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Zuchthunde -->
|
||||
|
|
@ -157,7 +155,7 @@ window.Page_breeder = (() => {
|
|||
display:flex;align-items:center;gap:var(--space-2)">
|
||||
${UI.icon('baby')} Aktuelle Würfe
|
||||
</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
${p.wuerfe.map(w => _wurfCard(w)).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
|
@ -192,8 +190,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="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
|
||||
style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd>
|
||||
<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>
|
||||
</div>` : ''}
|
||||
${seit ? _dl('Züchter seit', seit) : ''}
|
||||
</dl>
|
||||
|
|
@ -201,7 +199,7 @@ window.Page_breeder = (() => {
|
|||
|
||||
<!-- Fotos / Gallery -->
|
||||
${p.fotos?.length ? `
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-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
|
||||
|
|
@ -209,11 +207,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="${_esc(ph.url)}" target="_blank" rel="noopener noreferrer"
|
||||
<a href="${UI.escape(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="${_esc(ph.thumb)}" alt="${_esc(ph.caption)}"
|
||||
<img src="${UI.escape(ph.thumb)}" alt="${UI.escape(ph.caption)}"
|
||||
loading="${i < 6 ? 'eager' : 'lazy'}"
|
||||
style="width:100%;height:100%;object-fit:cover;display:block"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
|
|
@ -221,12 +219,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">${_esc(ph.caption)}</div>` : ''}
|
||||
color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${UI.escape(ph.caption)}</div>` : ''}
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div id="breeder-photos-section" style="display:none"></div>
|
||||
<div id="breeder-photos-section" class="hidden"></div>
|
||||
|
||||
</div>`;
|
||||
|
||||
|
|
@ -251,18 +249,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 ${_esc(hdTest.ergebnis)}</span>` : '',
|
||||
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '',
|
||||
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>` : '',
|
||||
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">${_esc(t)}</span>`
|
||||
border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${UI.escape(t)}</span>`
|
||||
).join('');
|
||||
|
||||
const genBadge = h.gentests_total > 0
|
||||
? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
? `<span class="text-xs-muted">
|
||||
${h.gentests_clear}/${h.gentests_total} Gentests frei
|
||||
</span>`
|
||||
: '';
|
||||
|
|
@ -271,12 +269,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 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>` : ''}
|
||||
<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>` : ''}
|
||||
${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)">${_esc(h.farbe)}</p>` : ''}
|
||||
${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(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}
|
||||
|
|
@ -318,16 +316,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)">${_esc(eltern)}</span>
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(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')} ${_esc(datum)}</span>` : ''}
|
||||
${datum ? `<span>${UI.icon('calendar-dots')} ${UI.escape(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')} ${_esc(w.preis_spanne)}</span>` : ''}
|
||||
${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${UI.escape(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">${_esc(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">${UI.escape(w.beschreibung)}</p>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -340,12 +338,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">${_esc(label)}</p>
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${UI.escape(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">${_esc(r.ergebnis || '—')}</span>
|
||||
<span style="color:var(--c-text-muted)">${r.cnt}×</span>
|
||||
<span style="font-weight:700">${UI.escape(r.ergebnis || '—')}</span>
|
||||
<span class="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('')}
|
||||
|
|
@ -359,8 +357,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">${_esc(label)}</dt>
|
||||
<dd style="margin:0;font-size:var(--text-sm)">${_esc(String(value))}</dd>
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -377,16 +375,16 @@ window.Page_breeder = (() => {
|
|||
const photos = await API.breederPhotos.list('breeder', breederId);
|
||||
if (!photos?.length) return;
|
||||
section.innerHTML = `
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-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="${_esc(ph.url||'')}" target="_blank" rel="noopener noreferrer"
|
||||
<a href="${UI.escape(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="${_esc(ph.thumbnail_url||ph.url||'')}" alt="${_esc(ph.caption||'')}"
|
||||
<img src="${UI.escape(ph.thumbnail_url||ph.url||'')}" alt="${UI.escape(ph.caption||'')}"
|
||||
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</a>`).join('')}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ window.Page_chat = (() => {
|
|||
el.innerHTML = convs.map(c => {
|
||||
const initials = (c.partner_name || '?')[0].toUpperCase();
|
||||
const preview = c.last_text
|
||||
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
|
||||
? UI.escape(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">${_esc(c.partner_name)}</div>
|
||||
<div class="chat-conv-name">${UI.escape(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" style="display:none"></span>
|
||||
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" class="hidden"></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/*" style="display:none"
|
||||
<input type="file" id="chat-photo-input" accept="image/*" class="hidden"
|
||||
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)">` : '') +
|
||||
_esc(m.text) +
|
||||
UI.escape(m.text) +
|
||||
(m.media_url ? `</div>` : '');
|
||||
}
|
||||
if (!bubbleContent) bubbleContent = _esc(m.text);
|
||||
if (!bubbleContent) bubbleContent = UI.escape(m.text);
|
||||
|
||||
html += `
|
||||
<div class="chat-bubble-row ${rowClass}">
|
||||
|
|
@ -450,13 +450,6 @@ 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Neue Nachricht — Freundesliste als Picker
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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" style="display:none"></div>
|
||||
<div id="diary-stats-bar" class="diary-stats-bar hidden"></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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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" style="margin-bottom:var(--space-3)">
|
||||
? `<div class="diary-detail-dogs mb-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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Titel <span class="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" style="display:none"></div>
|
||||
<div id="diary-new-media-grid" class="diary-media-grid hidden"></div>
|
||||
|
||||
<!-- versteckter Input — multiple für Mehrfachauswahl -->
|
||||
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
|
||||
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple class="hidden">
|
||||
|
||||
<!-- 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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Ort <span class="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 style="margin-top:var(--space-2)">
|
||||
<div class="mt-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' : ''} style="display:none">
|
||||
${entry?.is_milestone ? 'checked' : ''} class="hidden">
|
||||
<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" style="width:100%">
|
||||
<button type="submit" form="diary-form" class="btn btn-primary w-full">
|
||||
${isEdit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
|
||||
<label class="import-format-card" id="fmt-nsx">
|
||||
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
|
||||
<input type="radio" name="import-fmt" value="nsx" checked class="hidden">
|
||||
<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 style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
|
||||
<div class="text-xs-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" style="display:none">
|
||||
<input type="radio" name="import-fmt" value="csv" class="hidden">
|
||||
<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 style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
|
||||
<div class="text-xs-muted">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:var(--space-4)">
|
||||
<div class="mt-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 style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
|
||||
? `<details class="mt-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 style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
|
||||
${res.skipped ? `<span class="text-sm-muted"> · ${res.skipped} übersprungen</span>` : ''}
|
||||
${errHtml}
|
||||
</div>`;
|
||||
resultEl.style.display = 'block';
|
||||
|
|
|
|||
|
|
@ -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="${_esc(dog.name)}" class="dp-avatar-img"
|
||||
<img src="${dog.foto_url}" alt="${UI.escape(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)">${_esc(dog.name)}</h2>
|
||||
color:var(--c-text);margin:0 0 var(--space-1)">${UI.escape(dog.name)}</h2>
|
||||
${dog.rasse
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${UI.escape(dog.rasse)}</p>`
|
||||
: `<p style="margin:0 0 var(--space-2)"></p>`}
|
||||
|
||||
<!-- Rassen-Community-Chip (wird async geladen) -->
|
||||
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
|
||||
<div id="dp-same-breed-chip" class="mb-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" style="padding:var(--space-3)">
|
||||
<div class="card p-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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-secondary">
|
||||
${_calcAlter(dog.geburtstag)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${dog.geschlecht ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div class="card p-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" style="padding:var(--space-3)">
|
||||
<div class="card p-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" style="padding:var(--space-3)">
|
||||
<div class="card p-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">${_esc(dog.chip_nr)}</div>`
|
||||
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">nicht eingetragen
|
||||
? `<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
|
||||
<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">
|
||||
"${_esc(dog.bio)}"
|
||||
"${UI.escape(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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-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" style="padding:var(--space-4)">Lade…</div>
|
||||
<div id="dp-sitting-access" class="p-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>
|
||||
${_esc(skill.exercise_name)}
|
||||
${UI.escape(skill.exercise_name)}
|
||||
</span>`;
|
||||
};
|
||||
|
||||
const sitztBlock = sitzt.length ? `
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div class="mb-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" style="padding:var(--space-4)">
|
||||
<div class="card p-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" style="padding:var(--space-4)">
|
||||
<div class="card p-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 ${_esc(data.rasse_name)}` : ''}
|
||||
Pflegetipps${data.rasse_name ? ` für ${UI.escape(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')} ${_esc(t.titel)}
|
||||
${kat_icons[t.kategorie]||_ph('paw-print')} ${UI.escape(t.titel)}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px;
|
||||
line-height:1.5">${_esc(t.beschreibung||'')}</div>
|
||||
line-height:1.5">${UI.escape(t.beschreibung||'')}</div>
|
||||
${t.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted)">
|
||||
🔄 ${_esc(t.haeufigkeit)}</div>` : ''}
|
||||
🔄 ${UI.escape(t.haeufigkeit)}</div>` : ''}
|
||||
${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
|
||||
🛒 ${_esc(t.materialien)}</div>` : ''}
|
||||
🛒 ${UI.escape(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">${_esc(s)}</li>`).join('')}
|
||||
${t.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')}
|
||||
</ol>
|
||||
${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa;
|
||||
font-style:italic">💜 ${_esc(t.tipp)}</div>` : ''}
|
||||
font-style:italic">💜 ${UI.escape(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 style="margin-bottom:var(--space-3)">
|
||||
<div class="mb-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')} ${_esc(kat)}${katBadge}</div>
|
||||
${kat_icons[kat]||_ph('paw-print')} ${UI.escape(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">
|
||||
${_esc(tip.titel)}
|
||||
${UI.escape(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">${_esc(tip.beschreibung||'')}</div>
|
||||
line-height:1.5">${UI.escape(tip.beschreibung||'')}</div>
|
||||
${tip.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted);
|
||||
margin-top:4px">🔄 ${_esc(tip.haeufigkeit)}</div>` : ''}
|
||||
margin-top:4px">🔄 ${UI.escape(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">${_esc(s)}</li>`).join('')}
|
||||
${tip.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')}
|
||||
</ol>` : ''}
|
||||
${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa;
|
||||
font-style:italic">💜 ${_esc(tip.tipp)}</div>` : ''}
|
||||
font-style:italic">💜 ${UI.escape(tip.tipp)}</div>` : ''}
|
||||
</details>`).join('')}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
|
|
@ -499,12 +499,6 @@ window.Page_dog_profile = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SITTER-ZUGANG
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -527,8 +521,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>${_esc(s.sitter_name)}</strong>
|
||||
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
|
||||
<strong>${UI.escape(s.sitter_name)}</strong>
|
||||
<span class="text-muted"> · bis ${UI.escape(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">
|
||||
|
|
@ -538,7 +532,7 @@ window.Page_dog_profile = (() => {
|
|||
}
|
||||
|
||||
const friendOptions = friends.length
|
||||
? friends.map(f => `<option value="${f.friend_id}">${_esc(f.friend_name)}</option>`).join('')
|
||||
? friends.map(f => `<option value="${f.friend_id}">${UI.escape(f.friend_name)}</option>`).join('')
|
||||
: '<option value="" disabled>Keine Freunde vorhanden</option>';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
|
@ -547,26 +541,26 @@ window.Page_dog_profile = (() => {
|
|||
wrap.innerHTML = `
|
||||
${activeHtml}
|
||||
${friends.length ? `
|
||||
<div style="margin-top:var(--space-3)">
|
||||
<div class="mt-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" style="font-size:var(--text-xs)">Freund</label>
|
||||
<label class="form-label 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" style="font-size:var(--text-xs)">Gültig bis</label>
|
||||
<label class="form-label 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"
|
||||
style="margin-top:var(--space-2)">
|
||||
class="mt-2">
|
||||
Zugang gewähren
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -617,11 +611,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="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
|
||||
value="${UI.escape(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" style="width:100%">Speichern</button>
|
||||
<button class="btn btn-primary" id="chip-edit-save-btn" class="w-full">Speichern</button>
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>`,
|
||||
});
|
||||
|
|
@ -666,20 +660,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}"
|
||||
style="width:100%">
|
||||
class="w-full">
|
||||
</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/*" style="display:none">
|
||||
<input type="file" id="pe-file-input" accept="image/*" class="hidden">
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<div class="w3-btn-stack">
|
||||
${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-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''}
|
||||
<div class="flex-gap-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>
|
||||
|
|
@ -843,15 +837,15 @@ window.Page_dog_profile = (() => {
|
|||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
|
||||
${dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
|
||||
? `<img src="${UI.escape(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">${_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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -860,13 +854,13 @@ window.Page_dog_profile = (() => {
|
|||
|
||||
<!-- Owner + QR -->
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
${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)">${_esc(ownerName)}</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.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="${_esc(qrUrl)}"
|
||||
<img id="dp-vcard-qr" src="${UI.escape(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>
|
||||
|
|
@ -878,9 +872,9 @@ window.Page_dog_profile = (() => {
|
|||
UI.modal.open({
|
||||
title: 'Visitenkarte',
|
||||
body: `
|
||||
<div style="margin-bottom:var(--space-4)">${cardHtml}</div>
|
||||
<div class="mb-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 ${_esc(dog.name)} sofort öffnen.
|
||||
QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${UI.escape(dog.name)} sofort öffnen.
|
||||
</p>
|
||||
`,
|
||||
footer: `
|
||||
|
|
@ -935,7 +929,7 @@ window.Page_dog_profile = (() => {
|
|||
|
||||
async function _showShareModal(dog) {
|
||||
UI.modal.open({
|
||||
title: `${_esc(dog.name)} teilen`,
|
||||
title: `${UI.escape(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.
|
||||
|
|
@ -952,7 +946,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
|
||||
style="font-size:var(--text-xs)">
|
||||
class="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>
|
||||
|
|
@ -961,7 +955,7 @@ window.Page_dog_profile = (() => {
|
|||
Dieser Link kann einmalig angenommen werden.
|
||||
</p>
|
||||
</div>
|
||||
<div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`,
|
||||
<div id="share-list-wrap" class="mt-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>`,
|
||||
|
|
@ -1009,8 +1003,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>${_esc(s.shared_with_name)}</strong> · ${s.role}`
|
||||
: `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`}
|
||||
? `<strong>${UI.escape(s.shared_with_name)}</strong> · ${s.role}`
|
||||
: `<em class="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">
|
||||
|
|
@ -1056,7 +1050,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" style="width:100%">${UI.icon('dog')} Hund anlegen</button>
|
||||
<button type="submit" form="dp-form" class="btn btn-primary w-full">${UI.icon('dog')} Hund anlegen</button>
|
||||
<button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
|
|
@ -1073,8 +1067,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" style="width:100%">Speichern</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button type="submit" form="dp-form" class="btn btn-primary w-full">Speichern</button>
|
||||
<div class="flex-gap-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);
|
||||
|
|
@ -1101,19 +1095,19 @@ window.Page_dog_profile = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${_esc(dog?.name || '')}"
|
||||
value="${UI.escape(dog?.name || '')}"
|
||||
placeholder="z. B. Ban Yaro" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Rasse
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="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="${_esc(dog?.rasse || '')}"
|
||||
value="${UI.escape(dog?.rasse || '')}"
|
||||
list="dp-rasse-list"
|
||||
autocomplete="off"
|
||||
placeholder="z. B. Mischling, Golden Retriever…">
|
||||
|
|
@ -1126,7 +1120,7 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geburtstag</label>
|
||||
<input class="form-control" type="date" name="geburtstag"
|
||||
|
|
@ -1142,7 +1136,7 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Gewicht (kg)</label>
|
||||
<input class="form-control" type="number" name="gewicht_kg"
|
||||
|
|
@ -1160,14 +1154,14 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
|
||||
value="${UI.escape(dog?.chip_nr || '')}" placeholder="15-stellig">
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
|
@ -1175,7 +1169,7 @@ window.Page_dog_profile = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Felltyp
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
|
||||
</label>
|
||||
<select class="form-control" name="fell_typ">
|
||||
|
|
@ -1192,10 +1186,10 @@ window.Page_dog_profile = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Bio / Steckbrief
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control" name="bio" rows="2"
|
||||
placeholder="Kurze Beschreibung…">${_esc(dog?.bio || '')}</textarea>
|
||||
placeholder="Kurze Beschreibung…">${UI.escape(dog?.bio || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -1216,7 +1210,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/*" style="display:none"
|
||||
<input type="file" name="foto" accept="image/*" class="hidden"
|
||||
id="dp-form-foto">
|
||||
</label>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
|
||||
|
|
@ -1225,7 +1219,7 @@ window.Page_dog_profile = (() => {
|
|||
Rasse erkennen
|
||||
</button>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp"
|
||||
id="dp-rasse-foto-input" style="display:none">
|
||||
id="dp-rasse-foto-input" class="hidden">
|
||||
</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
|
||||
|
|
@ -1473,11 +1467,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 style="color:var(--c-text-secondary)">
|
||||
<p class="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)">${_esc(data.hinweis)}</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>` : ''}
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
|
|
@ -1490,24 +1484,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 ? '🐕 ' : ''}${_esc(r.name)}</div>
|
||||
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${UI.escape(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">${_esc(r.beschreibung)}</div>` : ''}
|
||||
${r.beschreibung ? `<div class="rasse-result-desc">${UI.escape(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="${_esc(r.name)}" style="flex:1">
|
||||
data-rasse="${UI.escape(r.name)}" class="flex-1">
|
||||
Rasse übernehmen
|
||||
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
|
||||
data-rasse="${_esc(r.name)}" style="flex:1">
|
||||
data-rasse="${UI.escape(r.name)}" class="flex-1">
|
||||
Diese wählen
|
||||
</button>`}
|
||||
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
|
||||
data-slug="${_esc(r.wiki_slug)}">
|
||||
data-slug="${UI.escape(r.wiki_slug)}">
|
||||
Im Wiki
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
|
@ -1521,7 +1515,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)">ℹ️ ${_esc(data.hinweis)}</div>` : ''}
|
||||
color:var(--c-text-secondary)">ℹ️ ${UI.escape(data.hinweis)}</div>` : ''}
|
||||
${cardsHtml}
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
|
||||
text-align:center">
|
||||
|
|
@ -1582,18 +1576,13 @@ window.Page_dog_profile = (() => {
|
|||
: `${j} Jahr${j !== 1 ? 'e' : ''} alt`;
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HUNDEPASS
|
||||
// ----------------------------------------------------------
|
||||
async function _showPassportModal(dog) {
|
||||
UI.modal.open({
|
||||
title: `Hundepass — ${_esc(dog.name)}`,
|
||||
title: `Hundepass — ${UI.escape(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">
|
||||
|
|
@ -1636,7 +1625,7 @@ window.Page_dog_profile = (() => {
|
|||
try {
|
||||
data = await API.get(`/passport/${dog.id}`);
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
|
||||
wrap.innerHTML = `<p class="text-danger">Fehler beim Laden: ${UI.escape(e.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1666,25 +1655,25 @@ window.Page_dog_profile = (() => {
|
|||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
|
||||
<div class="text-xs-secondary">Blutgruppe</div>
|
||||
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
|
||||
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
|
||||
${UI.escape(meta.blutgruppe) || '<span class="text-muted">nicht eingetragen</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
${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 class="mt-3">
|
||||
<div class="text-xs-secondary">Besonderheiten</div>
|
||||
<div id="pp-meta-besonderheiten" class="text-sm">
|
||||
${UI.escape(meta.besonderheiten)}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
|
@ -1708,13 +1697,13 @@ window.Page_dog_profile = (() => {
|
|||
: vaccs.map(v => `
|
||||
<div class="pp-vacc-row" data-id="${v.id}"
|
||||
class="pp-data-row">
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
|
||||
<div class="flex-1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(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 ? ` · ${_esc(v.tierarzt)}` : ''}
|
||||
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
|
||||
${v.tierarzt ? ` · ${UI.escape(v.tierarzt)}` : ''}
|
||||
${v.charge_nr ? ` · Charge: ${UI.escape(v.charge_nr)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
|
||||
|
|
@ -1727,7 +1716,7 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
|
||||
<!-- Medikamente -->
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div class="card p-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)">
|
||||
|
|
@ -1745,13 +1734,13 @@ window.Page_dog_profile = (() => {
|
|||
: meds.map(m => `
|
||||
<div class="pp-med-row" data-id="${m.id}"
|
||||
class="pp-data-row">
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
|
||||
<div class="flex-1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(m.name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
|
||||
${m.dosierung ? `${UI.escape(m.dosierung)} · ` : ''}
|
||||
${m.von ? `Von ${_fmt(m.von)}` : ''}
|
||||
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
|
||||
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
|
||||
${m.notiz ? ` · ${UI.escape(m.notiz)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
|
||||
|
|
@ -1813,17 +1802,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="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
|
||||
value="${UI.escape(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">${_esc(current.allergien || '')}</textarea>
|
||||
placeholder="z. B. Hühnchen, Flohspeichel">${UI.escape(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">${_esc(current.besonderheiten || '')}</textarea>
|
||||
placeholder="z. B. Herzprobleme, Angstpatient">${UI.escape(current.besonderheiten || '')}</textarea>
|
||||
</div>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||||
|
|
@ -1871,7 +1860,7 @@ window.Page_dog_profile = (() => {
|
|||
<option value="DHPP (Kombi)">
|
||||
</datalist>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Datum *</label>
|
||||
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
|
||||
|
|
@ -1938,13 +1927,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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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 style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
|
||||
<label class="form-label">Bis <span class="text-muted">(leer = dauerhaft)</span></label>
|
||||
<input id="pp-med-bis" class="form-control" type="date">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2001,7 +1990,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="${_esc(url)}" style="font-size:var(--text-xs)">
|
||||
value="${UI.escape(url)}" class="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>
|
||||
|
|
@ -2037,7 +2026,7 @@ window.Page_dog_profile = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
const name = _esc(data.dog_name);
|
||||
const name = UI.escape(data.dog_name);
|
||||
const km = data.gesamt_km || 0;
|
||||
const konfetti = km > 100;
|
||||
|
||||
|
|
@ -2079,8 +2068,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: ${_esc(data.lieblings_monat)}</div>` : ''}
|
||||
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</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>` : ''}
|
||||
`),
|
||||
_card(`
|
||||
<div style="font-size:2rem">🌡️</div>
|
||||
|
|
@ -2126,8 +2115,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: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>
|
||||
<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>
|
||||
</div>
|
||||
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
|
||||
`;
|
||||
|
|
@ -2299,7 +2288,7 @@ window.Page_dog_profile = (() => {
|
|||
// ----------------------------------------------------------
|
||||
async function _showTimelineModal(dog) {
|
||||
UI.modal.open({
|
||||
title: `Lebens-Timeline — ${_esc(dog.name)}`,
|
||||
title: `Lebens-Timeline — ${UI.escape(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>
|
||||
|
|
@ -2314,7 +2303,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 style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
|
||||
if (b) b.innerHTML = `<p class="text-danger">Fehler: ${UI.escape(e.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2351,14 +2340,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">${_esc(year)}</div>`;
|
||||
html += `<div class="tl-year">${UI.escape(year)}</div>`;
|
||||
lastYear = year;
|
||||
}
|
||||
|
||||
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
|
||||
const big = ev.is_milestone;
|
||||
|
||||
let label = _esc(ev.titel);
|
||||
let label = UI.escape(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}`;
|
||||
|
|
@ -2376,13 +2365,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(${_esc(ev.foto_url)})"></div>` : ''}
|
||||
<div class="tl-foto" style="background-image:url(${UI.escape(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>
|
||||
${_esc(kat.label)}
|
||||
${UI.escape(kat.label)}
|
||||
</span>
|
||||
<span class="tl-date">${_fmtDate(ev.datum)}</span>
|
||||
</div>
|
||||
|
|
@ -2451,8 +2440,8 @@ window.Page_dog_profile = (() => {
|
|||
if (!data || data.count === 0) return;
|
||||
const hauptRasse = data.rassen[0]?.rasse || '';
|
||||
const label = data.count === 1
|
||||
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
|
||||
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
|
||||
? `1 anderer ${UI.escape(hauptRasse)}-Halter in der App`
|
||||
: `${data.count} andere ${UI.escape(hauptRasse)}-Halter in der App`;
|
||||
|
||||
el.innerHTML = `
|
||||
<button class="breed-community-chip" id="dp-breed-chip-btn">
|
||||
|
|
@ -2498,7 +2487,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" style="width:100%">
|
||||
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary w-full">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
|
||||
Gedenkseite erstellen
|
||||
</button>
|
||||
|
|
@ -2550,22 +2539,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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
|
||||
<div class="text-xs-secondary">km zusammen</div>
|
||||
</div>` : ''}
|
||||
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
|
||||
<div class="text-xs-secondary">Tagebucheinträge</div>
|
||||
</div>` : ''}
|
||||
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
|
||||
<div class="text-xs-secondary">Fotos</div>
|
||||
</div>` : ''}
|
||||
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
|
||||
<div class="text-xs-secondary">gemeinsame Tage</div>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
|
|
@ -2596,8 +2585,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" style="margin-top:var(--space-4)">
|
||||
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
|
||||
<div id="gedenk-ki-wrap" class="mt-4">
|
||||
<button id="gedenk-ki-btn" class="btn btn-secondary w-full">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||||
Persönlichen Abschiedstext erstellen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -21,14 +21,6 @@ window.Page_ernaehrung = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// Escape helper
|
||||
// ------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// LIFECYCLE
|
||||
|
|
@ -156,17 +148,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="${_esc(gewichtDefault)}" placeholder="15">
|
||||
value="${UI.escape(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="${_esc(alterDefault)}" placeholder="3">
|
||||
value="${UI.escape(alterDefault)}" placeholder="3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktivität als Pill-Buttons -->
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-4">
|
||||
<div class="ern-section-label">🏃 Aktivität</div>
|
||||
<div class="ern-pill-group">
|
||||
<button class="ern-pill" data-akt="gering">🛋️ Gemütlich</button>
|
||||
|
|
@ -209,7 +201,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="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
|
||||
value="${UI.escape(_profil.marke)}" placeholder="z. B. Royal Canin">
|
||||
</div>
|
||||
<div class="by-form-group" style="margin:0">
|
||||
<label class="by-label">Portionen pro Tag</label>
|
||||
|
|
@ -219,7 +211,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...">${_esc(_profil.notizen)}</textarea>
|
||||
placeholder="Besonderheiten, Allergien...">${UI.escape(_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>
|
||||
|
|
@ -288,13 +280,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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
(~350 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${trocken} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -302,13 +294,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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
(~85 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${nass} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -316,13 +308,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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
(~150 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${barf} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<div class="text-sm-secondary">
|
||||
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -482,8 +474,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)">${_esc(item.name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-danger)">${_esc(item.grund)}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -511,7 +503,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>${_esc(dog?.name || 'deinen Hund')}</strong>.
|
||||
<strong>${UI.escape(dog?.name || 'deinen Hund')}</strong>.
|
||||
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
|
||||
</div>
|
||||
|
||||
|
|
@ -524,8 +516,8 @@ window.Page_ernaehrung = (() => {
|
|||
'Welche Leckerlis sind gesund?',
|
||||
].map(q => `
|
||||
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
|
||||
data-q="${_esc(q)}"
|
||||
style="font-size:var(--text-xs)">${_esc(q)}</button>
|
||||
data-q="${UI.escape(q)}"
|
||||
class="text-xs">${UI.escape(q)}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
|
|
@ -533,7 +525,7 @@ window.Page_ernaehrung = (() => {
|
|||
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
|
||||
|
||||
<!-- Eingabe -->
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-2">
|
||||
<textarea id="ern-ki-frage" class="by-input" rows="2"
|
||||
placeholder="Deine Frage zur Ernährung..."
|
||||
style="flex:1;resize:vertical"></textarea>
|
||||
|
|
@ -577,7 +569,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)">
|
||||
${_esc(frage)}
|
||||
${UI.escape(frage)}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
|
@ -586,7 +578,7 @@ window.Page_ernaehrung = (() => {
|
|||
// KI-Antwort Placeholder
|
||||
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
|
||||
chatEl.insertAdjacentHTML('beforeend', `
|
||||
<div id="${placeholderId}" style="margin-bottom:var(--space-3)">
|
||||
<div id="${placeholderId}" class="mb-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)">
|
||||
|
|
@ -617,7 +609,7 @@ window.Page_ernaehrung = (() => {
|
|||
}
|
||||
}
|
||||
|
||||
const antwortHtml = _esc(antwort)
|
||||
const antwortHtml = UI.escape(antwort)
|
||||
.replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
|
|
@ -746,7 +738,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="${_esc(n)}">`).join('');
|
||||
dl.innerHTML = names.map(n => `<option value="${UI.escape(n)}">`).join('');
|
||||
}).catch(() => {});
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
@ -905,7 +897,7 @@ window.Page_ernaehrung = (() => {
|
|||
try {
|
||||
data = await API.dogs.futterAnalyse(dog.id);
|
||||
} catch (_) {
|
||||
analyseEl.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Analyse nicht verfügbar.</p>`;
|
||||
analyseEl.innerHTML = `<p class="text-sm-muted">Analyse nicht verfügbar.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -950,7 +942,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>${_esc(data.hinweis)}</span>
|
||||
<span>${UI.escape(data.hinweis)}</span>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
|
@ -978,7 +970,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">
|
||||
${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
|
||||
${UI.escape(KAT_LABELS[kat] || kat)} ×${cnt}
|
||||
</span>`;
|
||||
}).join('');
|
||||
return `
|
||||
|
|
@ -988,17 +980,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">
|
||||
${_esc(f.name)}
|
||||
${UI.escape(f.name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
${_esc(TYP_LABELS[f.typ] || f.typ)} · ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
|
||||
<div class="text-xs-muted">
|
||||
${UI.escape(TYP_LABELS[f.typ] || f.typ)} · ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
|
||||
${f.status !== 'neu' ? `· <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''}
|
||||
</div>
|
||||
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
|
||||
</div>
|
||||
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
|
||||
color:${cfg.color};white-space:nowrap">
|
||||
${_esc(cfg.label)}
|
||||
${UI.escape(cfg.label)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1084,10 +1076,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 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)}
|
||||
<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)}
|
||||
${item.menge_g ? ` · ${item.menge_g} g` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1110,13 +1102,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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);color:${col}">
|
||||
${_esc(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
|
||||
${UI.escape(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
|
||||
<span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span>
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
${_esc(item.datum)} ${_esc(item.uhrzeit)}
|
||||
<div class="text-xs-muted">
|
||||
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon vert-del-reaktion" data-id="${item.id}"
|
||||
|
|
|
|||
|
|
@ -32,16 +32,17 @@ window.Page_erste_hilfe = (() => {
|
|||
land: 'Österreich',
|
||||
flag: 'AT',
|
||||
eintraege: [
|
||||
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
|
||||
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
|
||||
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' },
|
||||
{ label: 'VetMedUni Wien — Kleintier-Notdienst (24h)', tel: '+431250776900', display: '+43 1 25077-6900' },
|
||||
],
|
||||
},
|
||||
{
|
||||
land: 'Schweiz',
|
||||
flag: 'CH',
|
||||
eintraege: [
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -253,13 +254,13 @@ window.Page_erste_hilfe = (() => {
|
|||
</div>
|
||||
|
||||
${KATEGORIEN.map(k => `
|
||||
<div class="eh-tab-panel" id="eh-panel-${k.id}" style="display:none">
|
||||
<div class="eh-tab-panel" id="eh-panel-${k.id}" class="hidden">
|
||||
${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" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#info"></use></svg>
|
||||
<svg class="ph-icon" aria-hidden="true" class="text-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>
|
||||
|
|
@ -311,7 +312,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 style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="flex-col-gap-2">
|
||||
${g.eintraege.map(renderEintrag).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -323,7 +324,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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
${gruppen}
|
||||
</div>
|
||||
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
|
||||
|
|
@ -345,7 +346,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" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
|
||||
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
|
||||
Schnellübersicht: Was tun bei …
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
|
|
@ -485,7 +486,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">${_esc(parentLabel)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.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>
|
||||
|
|
|
|||
|
|
@ -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 style="flex:1"></div>
|
||||
<div class="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" style="display:none"></div>
|
||||
<div class="events-map" id="ev-map" class="hidden"></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" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
|
||||
title="Notiz" class="text-muted" onclick="event.stopPropagation()">
|
||||
${_icon('note-pencil')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -248,8 +248,10 @@ window.Page_events = (() => {
|
|||
await UI.loadLeaflet(true); // true = mit MarkerCluster
|
||||
|
||||
if (!_map) {
|
||||
_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);
|
||||
_map = await UI.map.create('ev-map', {
|
||||
center: [51.1657, 10.4515], zoom: 6,
|
||||
zoomControl: true, attributionControl: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Cluster-Gruppe aufräumen und neu befüllen
|
||||
|
|
@ -266,12 +268,8 @@ 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.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],
|
||||
});
|
||||
// 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>`;
|
||||
const popup = `
|
||||
<div style="min-width:180px">
|
||||
<strong>${UI.escape(ev.titel)}</strong><br>
|
||||
|
|
@ -282,7 +280,7 @@ window.Page_events = (() => {
|
|||
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
|
||||
</div>
|
||||
`;
|
||||
const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
|
||||
const m = UI.map.svgMarker(ev.lat, ev.lon, html, { size: 32, anchorY: 32 }).bindPopup(popup);
|
||||
_clusterGroup.addLayer(m);
|
||||
_markers.push(m);
|
||||
bounds.push([ev.lat, ev.lon]);
|
||||
|
|
@ -496,7 +494,7 @@ window.Page_events = (() => {
|
|||
<label class="form-label">GPS-Position</label>
|
||||
<div id="ev-location-picker"></div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:var(--space-3)">
|
||||
<div class="form-group mt-3">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
|
||||
</div>
|
||||
|
|
@ -509,10 +507,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" style="width:100%">
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" class="w-full">
|
||||
${isEdit ? 'Speichern' : 'Event erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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>
|
||||
|
|
@ -672,7 +670,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" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||
<svg class="ph-icon" aria-hidden="true" class="text-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>
|
||||
|
|
|
|||
|
|
@ -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') : ''} ${_esc(d.name)}
|
||||
${d.id ? UI.icon('paw-print') : ''} ${UI.escape(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="exp-fab" id="exp-fab" title="Neue Ausgabe">
|
||||
<button class="list-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" style="color:var(--c-primary)">${_fmt(jahr)}</div>
|
||||
<div class="exp-kachel-betrag text-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 class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
|
||||
? `<span>${UI.icon('paw-print')} ${UI.escape(e.dog_name)}</span>`
|
||||
: '';
|
||||
const notiz = e.notiz
|
||||
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
|
||||
? `<div class="list-item-text">${UI.escape(e.notiz)}</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="exp-entry" data-id="${e.id}">
|
||||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
|
||||
<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}">
|
||||
${UI.icon(k.icon)}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -313,15 +313,15 @@ window.Page_expenses = (() => {
|
|||
|
||||
return `
|
||||
<div class="exp-month-group">
|
||||
<div class="exp-month-header">
|
||||
<span class="exp-month-title">${titel}</span>
|
||||
<span class="exp-month-summe">${_fmt(summe)}</span>
|
||||
<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>
|
||||
${rows}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
|
||||
el.innerHTML = `<div class="list-shell">${html}</div><div style="height:80px"></div>`;
|
||||
|
||||
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
|
||||
el.querySelectorAll('.exp-entry').forEach(row => {
|
||||
|
|
@ -372,30 +372,26 @@ window.Page_expenses = (() => {
|
|||
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: '—';
|
||||
return `
|
||||
<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 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>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -407,7 +403,7 @@ window.Page_expenses = (() => {
|
|||
</button>
|
||||
</div>
|
||||
${recurring.length
|
||||
? `<div class="exp-list">${cards}</div>`
|
||||
? `<div class="list-shell">${cards}</div>`
|
||||
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
|
||||
title: 'Keine Daueraufträge',
|
||||
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
|
||||
|
|
@ -448,7 +444,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' : ''}>${_esc(d.name)}</option>`
|
||||
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${UI.escape(d.name)}</option>`
|
||||
).join('');
|
||||
|
||||
const body = `
|
||||
|
|
@ -458,9 +454,8 @@ window.Page_expenses = (() => {
|
|||
<select class="form-control" name="kategorie">${katOptions}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<label class="form-label">Betrag</label>
|
||||
${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Häufigkeit</label>
|
||||
|
|
@ -477,15 +472,15 @@ window.Page_expenses = (() => {
|
|||
</div>
|
||||
${dogOptions ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<label class="form-label">Hund <span class="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 style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<label class="form-label">Bezeichnung <span class="text-muted">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="notiz"
|
||||
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
||||
value="${UI.escape(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
|
|
@ -501,7 +496,7 @@ window.Page_expenses = (() => {
|
|||
const fd = UI.formData(e.target);
|
||||
const payload = {
|
||||
kategorie: fd.kategorie,
|
||||
betrag: parseFloat(fd.betrag),
|
||||
betrag: UI.parseMoney(fd.betrag),
|
||||
haeufigkeit: fd.haeufigkeit,
|
||||
startdatum: fd.startdatum,
|
||||
notiz: fd.notiz || null,
|
||||
|
|
@ -688,13 +683,13 @@ window.Page_expenses = (() => {
|
|||
|
||||
const defaultDogId = entry?.dog_id ?? _selectedDogId;
|
||||
const dogOptions = (_appState.dogs || []).map(d =>
|
||||
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
|
||||
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${UI.escape(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' : ''} style="display:none">
|
||||
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} class="hidden">
|
||||
<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('');
|
||||
|
|
@ -707,15 +702,10 @@ window.Page_expenses = (() => {
|
|||
<div class="exp-kat-grid">${katKacheln}</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label class="form-label">Betrag</label>
|
||||
<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>
|
||||
${UI.moneyInput({ name: 'betrag', value: entry?.betrag ?? '', required: true })}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label class="form-label">Datum</label>
|
||||
|
|
@ -735,7 +725,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="${_esc(entry?.notiz || '')}"
|
||||
value="${UI.escape(entry?.notiz || '')}"
|
||||
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
|
||||
</div>
|
||||
|
||||
|
|
@ -810,7 +800,7 @@ window.Page_expenses = (() => {
|
|||
const fd = UI.formData(ev.target);
|
||||
const payload = {
|
||||
kategorie: fd.kategorie,
|
||||
betrag: parseFloat(fd.betrag),
|
||||
betrag: UI.parseMoney(fd.betrag),
|
||||
datum: fd.datum,
|
||||
notiz: fd.notiz || null,
|
||||
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
|
||||
|
|
@ -862,14 +852,5 @@ window.Page_expenses = (() => {
|
|||
return Math.round(val) + ' €';
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init, refresh };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -39,12 +39,7 @@ window.Page_forum = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
|
|
@ -99,7 +94,7 @@ window.Page_forum = (() => {
|
|||
<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" style="color:var(--c-text-muted)">${UI.icon('info')} Regeln</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-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -108,7 +103,7 @@ window.Page_forum = (() => {
|
|||
<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">${_esc(k.label)}</span></button>
|
||||
data-kat="${k.key}"><span class="by-tab-text">${UI.escape(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>
|
||||
|
|
@ -217,7 +212,7 @@ window.Page_forum = (() => {
|
|||
.format(new Date(+year, +month - 1, 1));
|
||||
const top = data.top?.[0];
|
||||
const winnerLine = top
|
||||
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
|
||||
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
|
||||
: 'Noch keine Stimmen';
|
||||
const metaLine = top
|
||||
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
|
||||
|
|
@ -227,7 +222,7 @@ window.Page_forum = (() => {
|
|||
<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 · ${_esc(monthName)}</div>
|
||||
<div class="forum-hdm-tile-title">Hund des Monats · ${UI.escape(monthName)}</div>
|
||||
<div class="forum-hdm-tile-winner">${winnerLine}</div>
|
||||
<div class="forum-hdm-tile-meta">${metaLine}</div>
|
||||
</div>
|
||||
|
|
@ -251,16 +246,16 @@ window.Page_forum = (() => {
|
|||
? data.top.slice(0, 5).map((dog, i) => {
|
||||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i];
|
||||
const av = dog.foto_url
|
||||
? `<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]) : '';
|
||||
? `<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]) : '';
|
||||
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">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
|
||||
</div>
|
||||
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
|
||||
|
|
@ -280,7 +275,7 @@ window.Page_forum = (() => {
|
|||
<div class="hdm-kandidaten-search">
|
||||
<input type="search" id="hdm-search" class="form-control"
|
||||
placeholder="Name oder Rasse suchen …" autocomplete="off"
|
||||
style="font-size:var(--text-sm)">
|
||||
class="text-sm">
|
||||
</div>
|
||||
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
|
||||
${UI.skeleton(3)}
|
||||
|
|
@ -291,7 +286,7 @@ window.Page_forum = (() => {
|
|||
<div class="hdm-header">
|
||||
<div class="hdm-trophy">🏆</div>
|
||||
<h2 class="hdm-title">Hund des Monats</h2>
|
||||
<div class="hdm-monat">${_esc(monthName)}</div>
|
||||
<div class="hdm-monat">${UI.escape(monthName)}</div>
|
||||
</div>
|
||||
${voteHint}
|
||||
<div class="hdm-section">
|
||||
|
|
@ -320,16 +315,16 @@ window.Page_forum = (() => {
|
|||
grid.innerHTML = list.map(dog => {
|
||||
const isVoted = data.user_vote === dog.id;
|
||||
const av = dog.foto_url
|
||||
? `<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]) : '';
|
||||
? `<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]) : '';
|
||||
return `
|
||||
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
|
||||
<div class="hdm-vote-av">${av}</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>` : ''}
|
||||
<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>` : ''}
|
||||
<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'}
|
||||
|
|
@ -411,8 +406,8 @@ window.Page_forum = (() => {
|
|||
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 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">
|
||||
<p class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p>
|
||||
<button class="btn btn-primary mt-4" id="forum-first-btn">
|
||||
Ersten Beitrag erstellen
|
||||
</button>
|
||||
</div>`;
|
||||
|
|
@ -443,31 +438,31 @@ window.Page_forum = (() => {
|
|||
|
||||
function _threadCardHTML(t) {
|
||||
const preview = t.text_preview
|
||||
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
|
||||
? UI.escape(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="${_esc(t.foto_preview_url || t.foto_preview)}"
|
||||
${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''}
|
||||
: `<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"` : ''}
|
||||
alt="" loading="lazy"
|
||||
onerror="this.src='${_esc(t.foto_preview)}'">`
|
||||
onerror="this.src='${UI.escape(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--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
|
||||
<span class="forum-category-badge forum-category-badge--${UI.escape(t.kategorie)}">${UI.escape(t.kategorie)}</span>
|
||||
${pinBadge}${lockBadge}
|
||||
</div>
|
||||
<div class="forum-card-content">
|
||||
<div class="forum-card-main">
|
||||
<div class="forum-card-title">${_esc(t.titel)}</div>
|
||||
<div class="forum-card-title">${UI.escape(t.titel)}</div>
|
||||
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
|
||||
<div class="forum-card-meta">
|
||||
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
|
||||
<span>${UI.icon('user')} ${UI.escape(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>
|
||||
|
|
@ -493,7 +488,7 @@ window.Page_forum = (() => {
|
|||
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 style="color:var(--c-text-secondary)">Keine Ergebnisse für „${_esc(q)}"</p>
|
||||
<p class="text-secondary">Keine Ergebnisse für „${UI.escape(q)}"</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
|
@ -533,19 +528,19 @@ window.Page_forum = (() => {
|
|||
<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" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
|
||||
<button class="btn btn-ghost btn-sm forum-mod-delete-thread text-danger">${UI.icon('trash')} Thread</button>
|
||||
</div>` : '';
|
||||
|
||||
const _forumMediaHtml = (u) => {
|
||||
if (u.endsWith('.pdf'))
|
||||
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
|
||||
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
|
||||
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>`;
|
||||
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
|
||||
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
|
||||
return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
|
||||
return `<video src="${UI.escape(u)}" poster="${UI.escape(poster)}" controls playsinline
|
||||
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
|
||||
}
|
||||
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
|
||||
return `<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`;
|
||||
};
|
||||
const fotoGallery = (thread.foto_urls?.length)
|
||||
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
|
||||
|
|
@ -565,7 +560,7 @@ window.Page_forum = (() => {
|
|||
<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" style="display:none">
|
||||
<input type="file" accept="image/*" id="forum-reply-file" class="hidden">
|
||||
</label>
|
||||
<div id="forum-reply-previews" class="forum-upload-previews"></div>
|
||||
</div>
|
||||
|
|
@ -578,20 +573,20 @@ window.Page_forum = (() => {
|
|||
<div class="forum-thread-detail">
|
||||
${modToolbar}
|
||||
<div class="forum-thread-header-row">
|
||||
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
|
||||
<span class="forum-category-badge forum-category-badge--${UI.escape(thread.kategorie)}">${UI.escape(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">${_esc(thread.text)}</p>
|
||||
<p style="white-space:pre-wrap;word-break:break-word">${UI.escape(thread.text)}</p>
|
||||
${fotoGallery}
|
||||
</div>
|
||||
|
||||
<div class="forum-thread-author-row">
|
||||
<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>
|
||||
<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>
|
||||
${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}">
|
||||
|
|
@ -623,7 +618,7 @@ window.Page_forum = (() => {
|
|||
</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')} ${_esc(thread.titel)}`, body, footer });
|
||||
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${UI.escape(thread.titel)}`, body, footer });
|
||||
|
||||
// Close
|
||||
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
|
||||
|
|
@ -778,7 +773,7 @@ window.Page_forum = (() => {
|
|||
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="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
|
||||
`<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
|
|
@ -788,13 +783,13 @@ window.Page_forum = (() => {
|
|||
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">${_esc(_initial(p.autor_name))}</div>
|
||||
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
|
||||
<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>
|
||||
${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">${_esc(p.text)}</div>
|
||||
<div class="forum-post-text">${UI.escape(p.text)}</div>
|
||||
${fotoHtml}
|
||||
</div>
|
||||
<div class="forum-post-actions">
|
||||
|
|
@ -803,7 +798,7 @@ window.Page_forum = (() => {
|
|||
</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="${_esc(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="${UI.escape(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>
|
||||
|
|
@ -862,7 +857,7 @@ window.Page_forum = (() => {
|
|||
try {
|
||||
await API.forum.deletePost(postId);
|
||||
if (postEl) {
|
||||
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
|
||||
postEl.innerHTML = '<em class="text-muted">Beitrag wurde entfernt</em>';
|
||||
postEl.className = 'forum-post forum-post--deleted';
|
||||
}
|
||||
const idx = _threads.findIndex(t => t.id === threadId);
|
||||
|
|
@ -991,7 +986,7 @@ window.Page_forum = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function _showCreateForm() {
|
||||
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
|
||||
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
|
||||
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${UI.escape(k.label)}</option>`
|
||||
).join('');
|
||||
|
||||
const body = `
|
||||
|
|
@ -1011,16 +1006,16 @@ window.Page_forum = (() => {
|
|||
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Standort <span class="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 style="display:none">
|
||||
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple class="hidden">
|
||||
</div>
|
||||
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
|
||||
<div id="forum-thread-previews" class="forum-upload-previews mt-2"></div>
|
||||
</div>
|
||||
</form>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
|
||||
|
|
@ -1241,12 +1236,12 @@ window.Page_forum = (() => {
|
|||
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)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
|
||||
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||
});
|
||||
_clusterGroup.addLayer(
|
||||
L.marker([m.lat, m.lon], { icon })
|
||||
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
|
||||
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
|
||||
);
|
||||
});
|
||||
_map.addLayer(_clusterGroup);
|
||||
|
|
@ -1295,14 +1290,14 @@ window.Page_forum = (() => {
|
|||
? `<div class="forum-mod-reports">
|
||||
${reports.map(r => `
|
||||
<div class="forum-mod-report-item" data-id="${r.id}">
|
||||
<div style="font-size:var(--text-sm)">
|
||||
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
|
||||
— ${_esc(r.grund)}
|
||||
<div class="text-sm">
|
||||
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
|
||||
— ${UI.escape(r.grund)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
|
||||
<div class="text-xs-muted">
|
||||
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
|
||||
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" class="mt-2">
|
||||
${UI.icon('check')} Erledigt
|
||||
</button>
|
||||
</div>`).join('')}
|
||||
|
|
@ -1334,7 +1329,7 @@ window.Page_forum = (() => {
|
|||
title: 'Antwort bearbeiten',
|
||||
body: `<form id="${id}">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" name="text" rows="5" required>${_esc(currentText)}</textarea>
|
||||
<textarea class="form-control" name="text" rows="5" required>${UI.escape(currentText)}</textarea>
|
||||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
|
|
@ -1373,14 +1368,14 @@ window.Page_forum = (() => {
|
|||
body: `<form id="${id}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titel</label>
|
||||
<input class="form-control" name="titel" value="${_esc(thread.titel || '')}" required>
|
||||
<input class="form-control" name="titel" value="${UI.escape(thread.titel || '')}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Text</label>
|
||||
<textarea class="form-control" name="text" rows="5">${_esc(thread.text || '')}</textarea>
|
||||
<textarea class="form-control" name="text" rows="5">${UI.escape(thread.text || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label>
|
||||
<div id="forum-edit-location-picker"></div>
|
||||
</div>
|
||||
</form>`,
|
||||
|
|
@ -1426,7 +1421,7 @@ window.Page_forum = (() => {
|
|||
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:40px;height:40px;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:44px;height:44px;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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-secondary">
|
||||
Teile ihn — der andere tippt drauf und findet dich sofort.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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=${_esc(encodeURIComponent(myName))}
|
||||
banyaro.app/#friends?suche=${UI.escape(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="${_esc(prefill || '')}"
|
||||
value="${UI.escape(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,17 +278,19 @@ window.Page_friends = (() => {
|
|||
const text = item.text || '';
|
||||
const page = _ACTIVITY_PAGE[item.type] || '';
|
||||
const dogLabel = item.dog_name
|
||||
? `<span class="fr-activity-dog">${_esc(item.dog_name)}</span>`
|
||||
? `<span class="fr-activity-dog">${UI.escape(item.dog_name)}</span>`
|
||||
: '';
|
||||
|
||||
const avatar = item.dog_foto
|
||||
? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
|
||||
? `<img src="${UI.escape(item.dog_foto)}" alt="${UI.escape(item.dog_name || '')}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
class="fr-activity-avatar">`
|
||||
: item.avatar_url
|
||||
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
|
||||
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
class="fr-activity-avatar">`
|
||||
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
|
||||
${_esc((item.user_name || '?')[0].toUpperCase())}
|
||||
${UI.escape((item.user_name || '?')[0].toUpperCase())}
|
||||
</div>`;
|
||||
|
||||
const tag = page ? `button type="button"` : `div`;
|
||||
|
|
@ -301,17 +303,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#${_esc(item.icon)}"></use>
|
||||
<use href="/icons/phosphor.svg#${UI.escape(item.icon)}"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-activity-body">
|
||||
<div class="fr-activity-meta">
|
||||
<span class="fr-activity-user">${_esc(item.user_name)}</span>
|
||||
<span class="fr-activity-user">${UI.escape(item.user_name)}</span>
|
||||
${dogLabel}
|
||||
</div>
|
||||
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
|
||||
<div class="fr-activity-time">${_esc(ago)}</div>
|
||||
${text ? `<div class="fr-activity-text">${UI.escape(text)}</div>` : ''}
|
||||
<div class="fr-activity-time">${UI.escape(ago)}</div>
|
||||
</div>
|
||||
</${page ? 'button' : 'div'}>
|
||||
`;
|
||||
|
|
@ -351,7 +353,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">
|
||||
${_esc(r.requester_name)}
|
||||
${UI.escape(r.requester_name)}
|
||||
</div>
|
||||
${_dogPills(r.dogs, 2)}
|
||||
</div>
|
||||
|
|
@ -390,12 +392,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">
|
||||
${_esc((r.addressee_name || '?')[0].toUpperCase())}
|
||||
${UI.escape((r.addressee_name || '?')[0].toUpperCase())}
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div class="flex-1">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text)">${_esc(r.addressee_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
|
||||
color:var(--c-text)">${UI.escape(r.addressee_name)}</div>
|
||||
<div class="text-xs-muted">Anfrage ausstehend</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
|
||||
|
|
@ -471,20 +473,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="${_esc(f.friend_name)}"
|
||||
data-dogs="${_esc(JSON.stringify(dogs))}"
|
||||
data-profile="${_esc(JSON.stringify(profile))}">
|
||||
data-friend-name="${UI.escape(f.friend_name)}"
|
||||
data-dogs="${UI.escape(JSON.stringify(dogs))}"
|
||||
data-profile="${UI.escape(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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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)">
|
||||
${_esc(f.friend_name)}
|
||||
${UI.escape(f.friend_name)}
|
||||
</span>
|
||||
${_erfahrungSpan(f.erfahrung)}
|
||||
</div>
|
||||
|
|
@ -495,7 +497,7 @@ window.Page_friends = (() => {
|
|||
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
|
||||
${_dogPills(dogs, 3)}
|
||||
</div>`
|
||||
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
|
||||
: `<div class="text-xs-muted">Noch kein Hund eingetragen</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -504,7 +506,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="${_esc(f.friend_name)}"
|
||||
data-fr-note-name="${UI.escape(f.friend_name)}"
|
||||
title="Notiz"
|
||||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||
|
|
@ -536,13 +538,14 @@ 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 style="text-align:center">
|
||||
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
|
||||
<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'"
|
||||
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">
|
||||
${_esc(d.name)}
|
||||
${UI.escape(d.name)}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
@ -558,9 +561,10 @@ 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 style="text-align:center">
|
||||
<div class="text-center">
|
||||
${d.foto_url
|
||||
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
|
||||
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
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%;
|
||||
|
|
@ -569,9 +573,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)">${_esc(d.name)}</div>
|
||||
color:var(--c-text)">${UI.escape(d.name)}</div>
|
||||
${d.rasse
|
||||
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>`
|
||||
? `<div class="text-xs-secondary">${UI.escape(d.rasse)}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
@ -585,24 +589,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)">
|
||||
📍 ${_esc(profile.wohnort)}
|
||||
📍 ${UI.escape(profile.wohnort)}
|
||||
</div>`);
|
||||
}
|
||||
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) {
|
||||
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
parts.push(`<div class="text-sm-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)">
|
||||
${_esc(profile.bio)}
|
||||
${UI.escape(profile.bio)}
|
||||
</div>`);
|
||||
}
|
||||
if (profile.social_link) {
|
||||
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
|
||||
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
|
||||
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
|
||||
<a href="${UI.escape(profile.social_link)}" target="_blank" rel="noopener noreferrer"
|
||||
class="text-primary">${UI.escape(profile.social_link)}</a>
|
||||
</div>`);
|
||||
}
|
||||
if (!parts.length) return '';
|
||||
|
|
@ -623,7 +627,7 @@ window.Page_friends = (() => {
|
|||
</div>` : '';
|
||||
|
||||
UI.modal.open({
|
||||
title: _esc(friendName),
|
||||
title: UI.escape(friendName),
|
||||
body: `
|
||||
<div>
|
||||
${badgesHTML}
|
||||
|
|
@ -638,7 +642,7 @@ window.Page_friends = (() => {
|
|||
Nachricht schreiben
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="modal-remove-btn" form=""
|
||||
style="color:var(--c-danger)">
|
||||
class="text-danger">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
|
||||
Entfernen
|
||||
</button>
|
||||
|
|
@ -679,11 +683,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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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)">${_esc(u.name)}</span>
|
||||
color:var(--c-text)">${UI.escape(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)}
|
||||
|
|
@ -693,12 +697,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 => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' | ')}
|
||||
${u.dogs.map(d => UI.escape(d.name) + (d.rasse ? ` · ${UI.escape(d.rasse)}` : '')).join(' | ')}
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
|
||||
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
|
||||
data-user-id="${u.id}" data-user-name="${UI.escape(u.name)}">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -782,12 +786,14 @@ window.Page_friends = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function _userAvatar(name, firstDog, avatarUrl) {
|
||||
if (avatarUrl) {
|
||||
return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
|
||||
return `<img src="${UI.escape(avatarUrl)}" alt="${UI.escape(name)}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
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="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
|
||||
return `<img src="${UI.escape(firstDog.foto_url)}" alt="${UI.escape(firstDog.name)}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
|
||||
border:2px solid var(--c-primary);flex-shrink:0">`;
|
||||
}
|
||||
|
|
@ -797,7 +803,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)">
|
||||
${_esc((name || '?')[0].toUpperCase())}
|
||||
${UI.escape((name || '?')[0].toUpperCase())}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -817,7 +823,7 @@ window.Page_friends = (() => {
|
|||
|
||||
function _wohnortLine(wohnort) {
|
||||
if (!wohnort) return '';
|
||||
return `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">📍 ${_esc(wohnort)}</span>`;
|
||||
return `<span class="text-xs-muted">📍 ${UI.escape(wohnort)}</span>`;
|
||||
}
|
||||
|
||||
function _bioLine(bio, sichtbarkeit) {
|
||||
|
|
@ -826,7 +832,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">${_esc(text)}</div>`;
|
||||
-webkit-box-orient:vertical">${UI.escape(text)}</div>`;
|
||||
}
|
||||
|
||||
function _dogPills(dogs, max) {
|
||||
|
|
@ -838,7 +844,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)">
|
||||
🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''}
|
||||
🐕 ${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}
|
||||
</span>
|
||||
`).join('')}
|
||||
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''}
|
||||
|
|
@ -846,11 +852,6 @@ window.Page_friends = (() => {
|
|||
`;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
|
|
@ -880,7 +881,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">${_esc(parentLabel)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.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>
|
||||
|
|
|
|||
|
|
@ -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 style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="flex-col-gap-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 style="flex:1;min-width:0">
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(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">
|
||||
${_esc(f.name)}
|
||||
${UI.escape(f.name)}
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
@ -131,23 +131,19 @@ 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 style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
|
||||
<span class="text-sm-muted">frei</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="by-card" style="padding:var(--space-6);text-align:center">
|
||||
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
<p class="text-sm-muted">
|
||||
Noch keine Gründer — sei der Erste!
|
||||
</p>
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -103,7 +103,7 @@ window.Page_hilfe = (() => {
|
|||
</p>
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
|
||||
${_search
|
||||
? `Zu "${_esc(_search)}" wurde nichts gefunden.`
|
||||
? `Zu "${UI.escape(_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)">
|
||||
${_esc(label)}
|
||||
${UI.escape(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)
|
||||
: _esc(a.frage);
|
||||
: UI.escape(a.frage);
|
||||
|
||||
// Antwort: Zeilenumbrüche in <br> wandeln
|
||||
const antwortHtml = _search
|
||||
? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
|
||||
: _esc(a.antwort).replace(/\n/g, '<br>');
|
||||
: UI.escape(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 style="flex:1">${frageHtml}</span>
|
||||
<span class="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,20 +222,12 @@ window.Page_hilfe = (() => {
|
|||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _highlight(text, term) {
|
||||
if (!term) return text;
|
||||
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${safe})`, 'gi');
|
||||
return _esc(text).replace(re,
|
||||
return UI.escape(text).replace(re,
|
||||
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
|
||||
class="text-primary">hallo@banyaro.app</a><br>
|
||||
Oder nutze das Formular — wir antworten in der Regel innerhalb von 24 Stunden.
|
||||
</p>
|
||||
|
||||
<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)">
|
||||
<form id="contact-form" class="flex-col-gap-3">
|
||||
<div class="grid-2">
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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>`;
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ window.Page_jobs = (() => {
|
|||
</div>
|
||||
|
||||
<!-- Stellenbeschreibung -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div class="card mb-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)">
|
||||
|
|
@ -76,7 +75,7 @@ window.Page_jobs = (() => {
|
|||
</div>
|
||||
|
||||
<!-- Wen wir suchen -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div class="card mb-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)">
|
||||
|
|
@ -121,7 +120,7 @@ window.Page_jobs = (() => {
|
|||
const s = statusMap[app.status] || statusMap.pending;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center">
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div class="mb-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>
|
||||
|
|
@ -130,7 +129,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">${_esc(app.admin_note)}</div>` : ''}
|
||||
color:var(--c-text-secondary);text-align:left">${UI.escape(app.admin_note)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -147,13 +146,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 ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
|
||||
value="${u ? UI.escape(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 ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
|
||||
value="${u ? UI.escape(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)">
|
||||
|
|
@ -194,7 +193,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"
|
||||
style="padding:var(--space-2)">
|
||||
class="p-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.
|
||||
|
|
@ -205,7 +204,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" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
|
||||
<a href="#" id="jobs-login-link" class="text-primary">anmeldest oder registrierst</a>,
|
||||
bekommst du sofort den 14-tägigen Luna-Probezugang.
|
||||
</div>` : ''}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>${_esc(b.titel)}</strong></span>
|
||||
<span>${b.icon} <strong>${UI.escape(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">${_esc(b.tipps)}</p>
|
||||
<p style="color:var(--c-text);line-height:1.6">${UI.escape(b.tipps)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
@ -173,16 +173,16 @@ window.Page_knigge = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function _renderVoting() {
|
||||
const cards = SZENARIEN.map(s => `
|
||||
<div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
|
||||
<div class="card mb-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">
|
||||
${_esc(s.frage)}
|
||||
${UI.escape(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">
|
||||
${_esc(a.text)}
|
||||
${UI.escape(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 style="margin-bottom:var(--space-3)">
|
||||
<div class="mb-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') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
|
||||
${isU ? UI.icon('arrow-right') + ' ' : ''}${UI.escape(a.text)}${isR ? ' ' + UI.icon('check') : ''}
|
||||
</span>
|
||||
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
|
||||
<span class="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 style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
|
||||
<span class="text-secondary">${UI.escape(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…"
|
||||
style="margin-bottom:var(--space-3)"></textarea>
|
||||
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
|
||||
class="mb-3"></textarea>
|
||||
<button class="btn btn-primary" id="ki-rat-btn" class="w-full">
|
||||
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>
|
||||
${_esc(data.rat)}
|
||||
${UI.escape(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.'
|
||||
: _esc(err.message || 'Fehler beim KI-Abruf.')}
|
||||
: UI.escape(err.message || 'Fehler beim KI-Abruf.')}
|
||||
</div>
|
||||
`;
|
||||
result.style.display = 'block';
|
||||
|
|
@ -400,16 +400,7 @@ window.Page_knigge = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
|
|
|||
|
|
@ -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 style="color:var(--c-text-secondary)">Nur für verifizierte Züchter.</p></div>`;
|
||||
<p class="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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
|
||||
<span class="text-xs-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 style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
|
||||
`<p class="text-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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 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>` : ''}
|
||||
${h.rufname ? `<span class="text-sm-muted">"${UI.escape(h.rufname)}"</span>` : ''}
|
||||
${alter ? `<span class="text-xs-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 style="color:var(--c-text-muted)">${UI.icon('caret-down')}</span>
|
||||
<span class="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}"
|
||||
style="padding:var(--space-4)">
|
||||
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</p>
|
||||
class="p-4">
|
||||
<p class="text-sm-muted">Lädt…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -177,7 +177,7 @@ window.Page_laeufi = (() => {
|
|||
]);
|
||||
_renderHundContent(el, hundId, laeufiList, deckList);
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
|
||||
el.innerHTML = `<p class="text-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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 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>` : ''}
|
||||
${l.ende ? `<span class="text-xs-muted">→ ${_fmtDate(l.ende)}</span>
|
||||
<span class="text-xs-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" style="color:var(--c-danger)">
|
||||
title="Löschen" class="text-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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" style="color:var(--c-danger)">${UI.icon('trash')}</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>
|
||||
</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 style="color:var(--c-text-secondary)">${_fmtDate(m.datum)}</span>
|
||||
<span class="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" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<form id="laeufi-form" class="flex-col-gap-3">
|
||||
<div class="grid-2">
|
||||
<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" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<form id="deck-form" class="flex-col-gap-3">
|
||||
<div class="grid-2">
|
||||
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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 style="color:var(--c-text-muted)">Lädt…</p></div>`,
|
||||
body: `<div id="prog-modal-content"><p class="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 style="padding:var(--space-2)">${_fmtDate(t.datum)}</td>
|
||||
<td class="p-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}"
|
||||
style="color:var(--c-danger)">${UI.icon('trash')}</button>
|
||||
class="text-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" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<form id="prog-form" class="flex-col-gap-3">
|
||||
<div class="grid-2">
|
||||
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -19,15 +19,11 @@ 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)">${_esc(title)}</h3>
|
||||
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function _statusBadge(status) {
|
||||
const map = {
|
||||
|
|
@ -37,7 +33,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}">${_esc(s.label)}</span>`;
|
||||
return `<span class="badge ${s.cls}">${UI.escape(s.label)}</span>`;
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
|
|
@ -59,7 +55,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}">${_esc(s.label)}</span>`;
|
||||
return `<span class="badge badge-sm ${s.cls}">${UI.escape(s.label)}</span>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -101,7 +97,7 @@ window.Page_litters = (() => {
|
|||
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
|
||||
const logoUrl = _breederInfo?.logo_url || null;
|
||||
const logoHtml = logoUrl
|
||||
? `<img src="${_esc(logoUrl)}" alt="Logo"
|
||||
? `<img src="${UI.escape(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'">`
|
||||
|
|
@ -118,15 +114,15 @@ window.Page_litters = (() => {
|
|||
padding:var(--space-3) var(--space-4);
|
||||
display:flex;align-items:center;gap:var(--space-3)">
|
||||
${logoHtml}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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">${_esc(zwinger)}</h2>
|
||||
text-overflow:ellipsis;line-height:1.2">${UI.escape(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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
|
||||
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -232,7 +228,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 style="color:var(--c-text-muted)">Keine Würfe für diesen Filter.</p>
|
||||
<p class="text-muted">Keine Würfe für diesen Filter.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
|
@ -248,8 +244,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 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">
|
||||
<p class="text-secondary">Noch keine Würfe angelegt.</p>
|
||||
<button class="btn btn-primary mt-4" id="litters-first-btn">
|
||||
${UI.icon('plus')} Ersten Wurf anlegen
|
||||
</button>
|
||||
</div>`;
|
||||
|
|
@ -315,7 +311,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 => _esc(n)).join(' × ') || '—';
|
||||
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => UI.escape(n)).join(' × ') || '—';
|
||||
|
||||
// Datum + Countdown
|
||||
let datumChip = '';
|
||||
|
|
@ -325,10 +321,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 style="color:var(--c-danger)">überfällig</span>`
|
||||
: days === 0 ? `<span style="color:var(--c-success)">heute!</span>`
|
||||
const c = days < 0 ? `<span class="text-danger">überfällig</span>`
|
||||
: days === 0 ? `<span class="text-success">heute!</span>`
|
||||
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>`
|
||||
: `<span style="color:var(--c-text-muted)">${days}d</span>`;
|
||||
: `<span class="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>`;
|
||||
|
|
@ -341,7 +337,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')} ${_esc(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')} ${UI.escape(l.preis_spanne)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
|
|
@ -355,11 +351,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">${_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>` : ''}
|
||||
${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>` : ''}
|
||||
</div>` : ''}
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)">
|
||||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${elternLabel}</span>
|
||||
<span class="text-sm-secondary">${elternLabel}</span>
|
||||
${_statusBadge(l.status)}
|
||||
${sichtbarChip}
|
||||
</div>
|
||||
|
|
@ -390,21 +386,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"
|
||||
style="color:var(--c-danger)">
|
||||
class="text-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">${_esc(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">${UI.escape(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 style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</p>
|
||||
<p class="text-sm-muted">Lädt…</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
|
||||
style="margin-top:var(--space-3)">
|
||||
class="mt-3">
|
||||
${UI.icon('plus')} Welpen hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -412,10 +408,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 style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</p>
|
||||
<p class="text-sm-muted">Lädt…</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
|
||||
style="margin-top:var(--space-3)">
|
||||
class="mt-3">
|
||||
${UI.icon('plus')} Interessent eintragen
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -455,13 +451,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)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderPuppies(container, litterId, puppies) {
|
||||
if (!puppies.length) {
|
||||
container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Welpen eingetragen.</p>`;
|
||||
container.innerHTML = `<p class="text-sm-muted">Noch keine Welpen eingetragen.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -469,10 +465,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 ? _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>` : ''}
|
||||
<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>` : ''}
|
||||
${_puppyStatusBadge(p.status)}
|
||||
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" style="font-size:var(--text-xs);color:var(--c-text-secondary)"></span>
|
||||
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" class="text-xs-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}"
|
||||
|
|
@ -542,16 +538,16 @@ window.Page_litters = (() => {
|
|||
const puppyLabel = puppy.name || 'Welpe';
|
||||
|
||||
const body = `
|
||||
<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 id="weight-history" class="mb-3">
|
||||
<p class="text-sm-muted">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 style="flex:1">
|
||||
<div class="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 style="flex:1">
|
||||
<div class="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>
|
||||
|
|
@ -565,7 +561,7 @@ window.Page_litters = (() => {
|
|||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
|
||||
title: `${UI.icon('scales')} Gewichtsverlauf — ${UI.escape(puppyLabel)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
|
@ -600,7 +596,7 @@ window.Page_litters = (() => {
|
|||
try {
|
||||
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
|
||||
if (!weights || !weights.length) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Messungen eingetragen.</p>`;
|
||||
el.innerHTML = `<p class="text-sm-muted">Noch keine Messungen eingetragen.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -630,22 +626,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 style="text-align:center">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Aktuell</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs-muted">Aktuell</div>
|
||||
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Zunahme</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs-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 style="text-align:center">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Ø tägl.</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs-muted">Ø tägl.</div>
|
||||
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Messungen</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs-muted">Messungen</div>
|
||||
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -695,7 +691,7 @@ window.Page_litters = (() => {
|
|||
</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -737,7 +733,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)">${_esc(err.message || 'Fehler.')}</p>`;
|
||||
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -764,34 +760,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 style="font-size:var(--text-xs);color:var(--c-text-muted)">Trage Anfragen ein — mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
|
||||
<p class="text-xs-muted">Trage Anfragen ein — mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = header + `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="flex-col-gap-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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)">${_esc(e.name)}</span>
|
||||
<span style="font-weight:600;font-size:var(--text-sm)">${UI.escape(e.name)}</span>
|
||||
${_wlStatusBadge(e.status)}
|
||||
${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>` : ''}
|
||||
${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>` : ''}
|
||||
</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')} ${_esc(e.email)}</span>` : ''}
|
||||
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
|
||||
${e.email ? `<span>${UI.icon('envelope')} ${UI.escape(e.email)}</span>` : ''}
|
||||
${e.telefon ? `<span>${UI.icon('phone')} ${UI.escape(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">"${_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>` : ''}
|
||||
${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>` : ''}
|
||||
</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" style="color:var(--c-danger)">${UI.icon('trash')}</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>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
|
|
@ -820,22 +816,22 @@ window.Page_litters = (() => {
|
|||
UI.modal.open({
|
||||
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
|
||||
body: `
|
||||
<form id="wl-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<form id="wl-form" class="flex-col-gap-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input class="form-control" name="name" required value="${_esc(v.name || '')}">
|
||||
<input class="form-control" name="name" required value="${UI.escape(v.name || '')}">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">E-Mail</label>
|
||||
<input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
|
||||
<input class="form-control" type="email" name="email" value="${UI.escape(v.email || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Telefon</label>
|
||||
<input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
|
||||
<input class="form-control" name="telefon" value="${UI.escape(v.telefon || '')}">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Wunsch Geschlecht</label>
|
||||
<select class="form-control" name="wunsch_geschlecht">
|
||||
|
|
@ -846,14 +842,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="${_esc(v.wunsch_farbe || '')}">
|
||||
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${UI.escape(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?">${_esc(v.nachricht || '')}</textarea>
|
||||
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${UI.escape(v.nachricht || '')}</textarea>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-control" name="status">
|
||||
|
|
@ -867,7 +863,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="${_esc(v.notiz || '')}">
|
||||
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${UI.escape(v.notiz || '')}">
|
||||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
|
|
@ -919,15 +915,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="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
|
||||
return `<option value="${h.id}" data-name="${UI.escape(h.name)}" ${currentId == h.id ? 'selected' : ''}>${UI.escape(label)}</option>`;
|
||||
}).join('');
|
||||
return `
|
||||
<select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
|
||||
<select class="form-control" name="${idName}" id="${idName}-sel" class="mb-2">
|
||||
<option value="">— ${placeholder} —</option>
|
||||
${opts}
|
||||
</select>
|
||||
<input class="form-control" type="text" name="${name}" id="${name}-txt"
|
||||
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
|
||||
value="${UI.escape(currentName || '')}" placeholder="oder Namen frei eingeben">`;
|
||||
};
|
||||
|
||||
const rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l =>
|
||||
|
|
@ -949,11 +945,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="${_esc(v.wurf_name || '')}">
|
||||
value="${UI.escape(v.wurf_name || '')}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Vater</label>
|
||||
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
|
||||
|
|
@ -968,18 +964,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="${_esc(v.erwartetes_datum || '')}">
|
||||
value="${UI.escape(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="${_esc(v.geburt_datum || '')}">
|
||||
value="${UI.escape(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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Welpen gesamt</label>
|
||||
<input class="form-control" type="number" name="welpen_gesamt" min="0"
|
||||
|
|
@ -1005,19 +1001,19 @@ window.Page_litters = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">Preisspanne</label>
|
||||
<input class="form-control" type="text" name="preis_spanne"
|
||||
value="${_esc(v.preis_spanne || '')}" placeholder="z. B. 1.500 – 2.000 €">
|
||||
value="${UI.escape(v.preis_spanne || '')}" placeholder="z. B. 1.500 – 2.000 €">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3"
|
||||
placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
|
||||
placeholder="Elternlinie, Besonderheiten, Charakter…">${UI.escape(v.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Gesundheitstests <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Gesundheitstests <span class="text-secondary">(optional)</span></label>
|
||||
<textarea class="form-control" name="gesundheitstests" rows="2"
|
||||
placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
|
||||
placeholder="HD, ED, Gentest, Augenkontrolle…">${UI.escape(v.gesundheitstests || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -1028,9 +1024,9 @@ window.Page_litters = (() => {
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sichtbar bis <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Sichtbar bis <span class="text-secondary">(optional)</span></label>
|
||||
<input class="form-control" type="date" name="sichtbar_bis"
|
||||
value="${_esc(v.sichtbar_bis || '')}">
|
||||
value="${UI.escape(v.sichtbar_bis || '')}">
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
|
@ -1134,11 +1130,11 @@ window.Page_litters = (() => {
|
|||
const body = `
|
||||
<form id="puppy-form" autocomplete="off">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Name <span class="text-secondary">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${_esc(v.name || '')}" placeholder="z. B. Max">
|
||||
value="${UI.escape(v.name || '')}" placeholder="z. B. Max">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geschlecht</label>
|
||||
|
|
@ -1153,7 +1149,7 @@ window.Page_litters = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">Farbe / Fellzeichnung</label>
|
||||
<input class="form-control" type="text" name="farbe"
|
||||
value="${_esc(v.farbe || '')}" placeholder="z. B. schwarz-braun">
|
||||
value="${UI.escape(v.farbe || '')}" placeholder="z. B. schwarz-braun">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -1165,11 +1161,11 @@ window.Page_litters = (() => {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Chip-Nr.</label>
|
||||
<input class="form-control" type="text" name="chip_nr"
|
||||
value="${_esc(v.chip_nr || '')}" placeholder="15-stellig">
|
||||
value="${UI.escape(v.chip_nr || '')}" placeholder="15-stellig">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geburtsgewicht (g)</label>
|
||||
|
|
@ -1186,9 +1182,9 @@ window.Page_litters = (() => {
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notiz <span style="color:var(--c-text-secondary)">(intern)</span></label>
|
||||
<label class="form-label">Notiz <span class="text-secondary">(intern)</span></label>
|
||||
<textarea class="form-control" name="notiz" rows="2"
|
||||
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
|
||||
placeholder="Interne Notizen…">${UI.escape(v.notiz || '')}</textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
|
@ -1249,22 +1245,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 style="color:var(--c-danger)">*</span></label>
|
||||
<label class="form-label">Name des Käufers <span class="text-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 style="color:var(--c-danger)">*</span></label>
|
||||
<label class="form-label">Adresse des Käufers <span class="text-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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">E-Mail des Käufers <span class="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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Kaufpreis <span class="text-secondary">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="preis"
|
||||
placeholder="z. B. 1.500 €">
|
||||
</div>
|
||||
|
|
@ -1279,7 +1275,7 @@ window.Page_litters = (() => {
|
|||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
|
||||
title: `${UI.icon('file-text')} Kaufvertrag — ${UI.escape(puppyLabel)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
|
@ -1317,11 +1313,11 @@ window.Page_litters = (() => {
|
|||
const visOrder = ['public', 'inquiry', 'private'];
|
||||
|
||||
const body = `
|
||||
<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 id="${galleryId}" class="mb-4">
|
||||
<p class="text-sm-muted">Lädt…</p>
|
||||
</div>
|
||||
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
|
||||
<form id="${uploadFormId}" style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<form id="${uploadFormId}" class="flex-col-gap-2">
|
||||
<label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
|
||||
${UI.icon('upload-simple')} Foto hochladen
|
||||
</label>
|
||||
|
|
@ -1336,7 +1332,7 @@ window.Page_litters = (() => {
|
|||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
|
||||
title: `${UI.icon('images')} Fotos — ${UI.escape(label)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
|
@ -1348,7 +1344,7 @@ window.Page_litters = (() => {
|
|||
try {
|
||||
const photos = await API.breederPhotos.list(entityType, entityId);
|
||||
if (!photos.length) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos vorhanden.</p>`;
|
||||
el.innerHTML = `<p class="text-sm-muted">Noch keine Fotos vorhanden.</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
|
|
@ -1358,21 +1354,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="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
|
||||
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
|
||||
<a href="${UI.escape(ph.url || '')}" target="_blank" rel="noopener noreferrer">
|
||||
<img src="${UI.escape(thumb)}" alt="${UI.escape(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="${_esc(ph.visibility)}"
|
||||
data-vis="${UI.escape(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">
|
||||
${_esc(vis.text)}
|
||||
${UI.escape(vis.text)}
|
||||
</button>
|
||||
<button class="photos-del-btn"
|
||||
data-photo-id="${ph.id}"
|
||||
|
|
@ -1418,7 +1414,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)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1464,13 +1460,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 style="font-size:var(--text-sm)">${_esc(i.text)}</span>
|
||||
<span class="text-sm">${UI.escape(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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
|
||||
<span class="text-sm-secondary">${UI.escape(p)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
|
||||
|
|
@ -1500,7 +1496,7 @@ window.Page_litters = (() => {
|
|||
Trotzdem fortfahren
|
||||
</button>
|
||||
</div>` : `
|
||||
<button class="btn btn-primary" data-modal-close style="width:100%">
|
||||
<button class="btn btn-primary" data-modal-close class="w-full">
|
||||
${UI.icon('check')} Verstanden
|
||||
</button>`,
|
||||
});
|
||||
|
|
@ -1540,7 +1536,7 @@ window.Page_litters = (() => {
|
|||
} catch (err) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
|
||||
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
body: `<p class="text-danger">${UI.escape(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
|
|
@ -1548,7 +1544,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">${_esc(text)}</div>`,
|
||||
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${UI.escape(text)}</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
|
||||
${UI.icon('clipboard-text')} Kopieren
|
||||
|
|
|
|||
|
|
@ -130,54 +130,24 @@ window.Page_lost = (() => {
|
|||
document.getElementById('lost-btn-report')
|
||||
?.addEventListener('click', _showReportForm);
|
||||
|
||||
await _loadLeaflet();
|
||||
_initMap();
|
||||
await _initMap();
|
||||
setTimeout(() => _map?.invalidateSize(), 100);
|
||||
await _locateAndLoad();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LEAFLET DYNAMISCH LADEN
|
||||
// KARTE INITIALISIEREN (lädt Leaflet via UI.map.create)
|
||||
// ----------------------------------------------------------
|
||||
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() {
|
||||
async function _initMap() {
|
||||
_injectStyles();
|
||||
const mapEl = document.getElementById('lost-map');
|
||||
if (!mapEl || !window.L || _map) return;
|
||||
if (!mapEl || _map) return;
|
||||
|
||||
_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);
|
||||
_map = await UI.map.create('lost-map', {
|
||||
center: [51.1657, 10.4515], zoom: 6,
|
||||
zoomControl: true, attributionControl: false,
|
||||
});
|
||||
_leafletLoaded = true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -303,26 +273,21 @@ 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 icon = L.divIcon({
|
||||
className : '',
|
||||
html : `<div style="background:${dotColor};color:#fff;border-radius:50%;
|
||||
const 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>`,
|
||||
iconSize : [34, 34],
|
||||
iconAnchor : [17, 17],
|
||||
});
|
||||
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`;
|
||||
|
||||
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 = L.marker([r.lat, r.lon], { icon })
|
||||
const marker = UI.map.svgMarker(r.lat, r.lon, html, { size: 34, anchorY: 17 })
|
||||
.addTo(_map)
|
||||
.bindPopup(`
|
||||
<b>🔍 ${_escape(r.name)}</b><br>
|
||||
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
|
||||
<b>🔍 ${UI.escape(r.name)}</b><br>
|
||||
${r.rasse ? UI.escape(r.rasse) + '<br>' : ''}
|
||||
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
|
||||
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
|
||||
<small>📅 ${_fmtDate(r.created_at)}</small>
|
||||
|
|
@ -413,14 +378,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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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)">
|
||||
${_escape(r.name)}
|
||||
${UI.escape(r.name)}
|
||||
</span>
|
||||
${r.rasse
|
||||
? `<span class="badge">${_escape(r.rasse)}</span>`
|
||||
? `<span class="badge">${UI.escape(r.rasse)}</span>`
|
||||
: ''}
|
||||
${isOwn
|
||||
? '<span class="badge badge-warning">Meine Meldung</span>'
|
||||
|
|
@ -434,11 +399,11 @@ window.Page_lost = (() => {
|
|||
</div>
|
||||
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
|
||||
color:var(--c-text)">
|
||||
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
|
||||
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
|
||||
</p>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-secondary">
|
||||
Gemeldet ${_fmtDate(r.created_at)}
|
||||
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
|
||||
${r.melder_name ? '· ' + UI.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">
|
||||
|
|
@ -450,10 +415,10 @@ window.Page_lost = (() => {
|
|||
🗑 Verwerfen
|
||||
</button>
|
||||
</div>`
|
||||
: (_appState.user ? `<div style="margin-top:var(--space-2)">
|
||||
: (_appState.user ? `<div class="mt-2">
|
||||
<button class="btn btn-ghost btn-xs lost-note-btn"
|
||||
data-lost-note-id="${r.id}"
|
||||
data-lost-note-name="${_escape(r.name)}"
|
||||
data-lost-note-name="${UI.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>
|
||||
|
|
@ -482,19 +447,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">🐕 ${_escape(r.name)}</span>
|
||||
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
|
||||
<span class="badge badge-danger">🐕 ${UI.escape(r.name)}</span>
|
||||
${r.rasse ? `<span class="badge">${UI.escape(r.rasse)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
|
||||
${_escape(r.beschreibung)}
|
||||
${UI.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: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
|
||||
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name.split(' ')[0])}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
|
|
@ -508,7 +473,7 @@ window.Page_lost = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
|
||||
UI.modal.open({ title: `🔍 ${UI.escape(r.name)} wird vermisst`, body });
|
||||
|
||||
document.getElementById('detail-lost-map')?.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
|
|
@ -546,10 +511,10 @@ window.Page_lost = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function _showFoundDialog(r) {
|
||||
UI.modal.open({
|
||||
title: `🎉 ${_escape(r.name)} gefunden?`,
|
||||
title: `🎉 ${UI.escape(r.name)} gefunden?`,
|
||||
body: `
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
|
||||
Wurde ${UI.escape(r.name)} wiedergefunden? Die Meldung wird als
|
||||
abgeschlossen markiert und aus der Liste entfernt.
|
||||
</p>
|
||||
`,
|
||||
|
|
@ -590,7 +555,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}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
|
||||
dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}${d.rasse ? ' (' + UI.escape(d.rasse) + ')' : ''}</option>`).join('')
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
|
|
@ -600,7 +565,7 @@ window.Page_lost = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Registrierter Hund
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
</label>
|
||||
<select class="form-control" name="dog_id" id="lf-dog-select">
|
||||
${dogOpts}
|
||||
|
|
@ -616,7 +581,7 @@ window.Page_lost = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Rasse
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
</label>
|
||||
<input class="form-control" type="text" name="rasse"
|
||||
placeholder="z. B. Labrador">
|
||||
|
|
@ -643,7 +608,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" style="color:var(--c-text-secondary)">
|
||||
<small id="lf-gps-hint" class="text-secondary">
|
||||
${_userPos
|
||||
? '✅ Aktueller Standort vorausgefüllt'
|
||||
: 'GPS-Button drücken um Standort zu ermitteln'}
|
||||
|
|
@ -653,7 +618,7 @@ window.Page_lost = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Foto
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
</label>
|
||||
<input class="form-control" type="file" name="photo"
|
||||
accept="image/*" capture="environment">
|
||||
|
|
@ -825,17 +790,7 @@ window.Page_lost = (() => {
|
|||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function _escape(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
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>
|
||||
|
|
@ -864,7 +819,7 @@ window.Page_lost = (() => {
|
|||
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">${_escape(parentLabel)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.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>
|
||||
|
|
|
|||
|
|
@ -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" style="display:none"></span>
|
||||
<span id="map-osm-status" class="hidden"></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(_esc(b.zwingername), { direction: 'top', offset: [0, -16] });
|
||||
.bindTooltip(UI.escape(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">${_esc(b.rasse_text)}</div>` : '';
|
||||
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
|
||||
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>` : '';
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="min-width:170px;max-width:240px">
|
||||
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(b.zwingername)}</div>
|
||||
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${UI.escape(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' : ''}
|
||||
style="display:none" class="rec-dog-cb">
|
||||
class="rec-dog-cb hidden">
|
||||
${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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="grid-2">
|
||||
<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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
|
||||
<textarea class="form-control" name="beschreibung" rows="2"
|
||||
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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" 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=""
|
||||
<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=""
|
||||
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)">
|
||||
${_esc(f.rasse_name || f.rasse_slug)}
|
||||
${UI.escape(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 ${_esc(f.user_name)}
|
||||
von ${UI.escape(f.user_name)}
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div class="mb-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="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
||||
<img src="${UI.escape(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 style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-2">
|
||||
<button class="btn btn-sm btn-primary mod-foto-approve"
|
||||
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>
|
||||
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>
|
||||
<button class="btn btn-sm btn-ghost mod-foto-reject"
|
||||
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>
|
||||
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>
|
||||
</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 style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="flex-col-gap-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)">
|
||||
${_esc(u.name[0].toUpperCase())}
|
||||
${UI.escape(u.name[0].toUpperCase())}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text)">
|
||||
${_esc(u.name)}
|
||||
${UI.escape(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 style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
${_esc(u.email)} ·
|
||||
<div class="text-xs-muted">
|
||||
${UI.escape(u.email)} ·
|
||||
<span style="color:${
|
||||
u.rolle === 'admin' ? 'var(--c-danger)'
|
||||
: u.rolle === 'moderator' ? '#f59e0b'
|
||||
: 'var(--c-text-muted)'}">
|
||||
${_esc(u.rolle)}
|
||||
${UI.escape(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="${_esc(u.name)}"
|
||||
title="Sperre aufheben" style="color:var(--c-success)">
|
||||
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
|
||||
title="Sperre aufheben" class="text-success">
|
||||
${UI.icon('lock-open')}
|
||||
</button>`
|
||||
: `<button class="btn btn-sm btn-ghost mod-ban"
|
||||
data-uid="${u.id}" data-name="${_esc(u.name)}"
|
||||
title="Sperren" style="color:var(--c-danger)">
|
||||
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
|
||||
title="Sperren" class="text-danger">
|
||||
${UI.icon('lock')}
|
||||
</button>`)
|
||||
: ''
|
||||
|
|
@ -400,27 +400,27 @@ window.Page_moderation = (() => {
|
|||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
||||
margin-bottom:var(--space-1)">
|
||||
${_esc(r.target_type)} #${r.target_id} ·
|
||||
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
|
||||
${UI.escape(r.target_type)} #${r.target_id} ·
|
||||
Gemeldet von <strong>${UI.escape(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: ${_esc(r.grund)}
|
||||
Grund: ${UI.escape(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)">
|
||||
${_esc(r.content_preview)}
|
||||
${UI.escape(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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
${edits.map(e => `
|
||||
<div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
|
||||
<div class="card p-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">${_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)}
|
||||
<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)}
|
||||
· ${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 style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
|
||||
<div class="text-sm">${UI.escape(e.old_value) || '<em class="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">${_esc(e.new_value)}</div>
|
||||
<div style="font-size:var(--text-sm);font-weight:600">${UI.escape(e.new_value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${e.status === 'pending' ? `
|
||||
|
|
@ -532,15 +532,6 @@ window.Page_moderation = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
|
|
|
|||
|
|
@ -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="${_esc(_search)}" autocomplete="off">
|
||||
placeholder="Film, Serie oder Rasse suchen …" value="${UI.escape(_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" style="margin-top:var(--space-2)">
|
||||
<div class="movies-filter-row mt-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 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>` : '';
|
||||
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>` : '';
|
||||
|
||||
return `
|
||||
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
||||
<div class="movie-card" data-film-id="${UI.escape(film.id)}">
|
||||
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
||||
<div class="movie-card-body">
|
||||
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
||||
<div class="movie-card-title">${UI.escape(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>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
|
||||
<span>${UI.escape(film.genre)}</span>${typLabel ? `<span class="text-xs-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> ${_esc(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> ${UI.escape(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">${_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 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">${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)">${_esc(film.beschreibung)}</p>
|
||||
<div style="margin-bottom:var(--space-2)">
|
||||
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${UI.escape(film.beschreibung)}</p>
|
||||
<div class="mb-2">
|
||||
<strong>Community-Bewertung:</strong>
|
||||
</div>
|
||||
<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)">
|
||||
<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)">
|
||||
Ø ${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="${_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>`;
|
||||
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>`;
|
||||
}).join('');
|
||||
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
|
||||
return `<div class="movie-star-rating" data-film-id="${UI.escape(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">${_esc(p.name)}</div>
|
||||
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
|
||||
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
|
||||
<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>
|
||||
</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="${_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>`;
|
||||
? `<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>`;
|
||||
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">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(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="${_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]) : '';
|
||||
? `<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]) : '';
|
||||
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">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(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">${_esc(monthName)}</div>
|
||||
<div class="hdm-monat">${UI.escape(monthName)}</div>
|
||||
</div>
|
||||
|
||||
${voteSection}
|
||||
|
|
@ -465,16 +465,7 @@ window.Page_movies = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _esc(str) {
|
||||
if (!str && str !== 0) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
|
|
|||
|
|
@ -47,14 +47,6 @@ window.Page_notes = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _formatTime(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
|
|
@ -76,7 +68,10 @@ window.Page_notes = (() => {
|
|||
} catch (_) { return 'Älteres'; }
|
||||
}
|
||||
|
||||
function _truncate(str, max = 150) {
|
||||
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.
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.slice(0, max) + '…' : str;
|
||||
}
|
||||
|
|
@ -125,7 +120,7 @@ window.Page_notes = (() => {
|
|||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => `
|
||||
<div class="notes-group">
|
||||
<div class="notes-group-label">${_esc(label)}</div>
|
||||
<div class="list-group-header">${UI.escape(label)}</div>
|
||||
${items.map(_noteCard).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
@ -166,9 +161,9 @@ window.Page_notes = (() => {
|
|||
<div class="notes-filter-chips">
|
||||
${RUBRIKEN.map(r => `
|
||||
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
|
||||
data-type="${_esc(r.type)}"
|
||||
data-type="${UI.escape(r.type)}"
|
||||
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
|
||||
${_esc(r.label)}
|
||||
${UI.escape(r.label)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
|
@ -178,7 +173,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="${_esc(_searchQ)}">
|
||||
placeholder="Suche…" value="${UI.escape(_searchQ)}">
|
||||
</div>
|
||||
<div class="notes-sort-btns">
|
||||
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
|
||||
|
|
@ -243,21 +238,32 @@ 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-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; }
|
||||
/* 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; } */
|
||||
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
|
||||
.notes-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); }
|
||||
/* 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-list { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
|
@ -285,11 +291,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> ${_esc(_kiError)}</div>` : ''}
|
||||
${_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>` : ''}
|
||||
${_kiSuggestions ? `
|
||||
<div class="notes-ki-suggestions">
|
||||
<ul>
|
||||
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
|
||||
${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
|
@ -314,43 +320,42 @@ window.Page_notes = (() => {
|
|||
const hasLocation = !!note.location_name;
|
||||
|
||||
return `
|
||||
<div class="notes-card" data-id="${note.id}">
|
||||
<div class="list-item-card list-item-card--clickable notes-card" data-id="${note.id}">
|
||||
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
|
||||
<div class="notes-card-top">
|
||||
<span class="notes-rubrik-chip"
|
||||
style="background:${rb.color}22;color:${rb.color}">
|
||||
<span class="list-item-chip" style="--chip-color:${rb.color}">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg>
|
||||
${_esc(rb.label)}
|
||||
${UI.escape(rb.label)}
|
||||
</span>
|
||||
${note.parent_label
|
||||
? `<span class="notes-parent-label" title="${_esc(note.parent_label)}">${_esc(note.parent_label)}</span>`
|
||||
? `<span class="notes-parent-label" title="${UI.escape(note.parent_label)}">${UI.escape(note.parent_label)}</span>`
|
||||
: ''
|
||||
}
|
||||
<div class="notes-card-actions">
|
||||
<button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
|
||||
<div class="list-item-actions notes-card-actions">
|
||||
<button class="list-item-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="notes-action-btn notes-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
|
||||
<button class="list-item-action-btn list-item-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="notes-card-text">${_esc(_truncate(note.text))}</p>
|
||||
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
|
||||
|
||||
<!-- Micro-Badges -->
|
||||
${microBadges.length ? `
|
||||
<div class="notes-micro-badges">
|
||||
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
|
||||
<div class="list-item-micro-badges">
|
||||
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Meta: Zeit + Ort -->
|
||||
<div class="notes-card-meta">
|
||||
<div class="list-item-meta-row">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg>
|
||||
${_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)}` : ''}
|
||||
${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)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -460,6 +465,64 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -499,7 +562,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 style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-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 => `
|
||||
|
|
@ -508,13 +571,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">
|
||||
${_esc(r.label)}
|
||||
${UI.escape(r.label)}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-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);
|
||||
|
|
@ -524,9 +587,9 @@ window.Page_notes = (() => {
|
|||
box-sizing:border-box"></textarea>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
</div>`;
|
||||
};
|
||||
|
|
@ -601,7 +664,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> ${_esc(rb.label)}
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${UI.escape(rb.label)}
|
||||
</span>
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
|
||||
Notiz bearbeiten
|
||||
|
|
@ -619,7 +682,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">${_esc(note.text)}</textarea>
|
||||
box-sizing:border-box">${UI.escape(note.text)}</textarea>
|
||||
</div>
|
||||
|
||||
${note.parent_type === 'training_session' ? `
|
||||
|
|
@ -627,7 +690,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 style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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);
|
||||
|
|
@ -642,7 +705,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 style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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);
|
||||
|
|
@ -657,7 +720,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 style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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);
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ window.Page_onboarding = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function _step1() {
|
||||
return `
|
||||
<div style="text-align:center">
|
||||
<div class="text-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 style="font-size:var(--text-xs);color:var(--c-text-secondary)">${desc}</div>
|
||||
<div class="text-xs-secondary">${desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<button class="btn btn-primary" id="ob-next-btn" style="width:100%">
|
||||
<div class="flex-col-gap-3">
|
||||
<button class="btn btn-primary" id="ob-next-btn" class="w-full">
|
||||
<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" style="width:100%">
|
||||
<button class="btn btn-ghost" id="ob-skip-btn" class="w-full">
|
||||
Ü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/*" style="display:none">
|
||||
accept="image/*" class="hidden">
|
||||
</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"
|
||||
style="flex:1">
|
||||
class="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" style="font-size:var(--text-sm)">
|
||||
<button class="btn btn-ghost" id="ob-skip-btn" class="text-sm">
|
||||
Ohne Hund fortfahren
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -255,7 +255,7 @@ window.Page_onboarding = (() => {
|
|||
function _step3() {
|
||||
const dogName = _appState.activeDog?.name;
|
||||
return `
|
||||
<div style="text-align:center">
|
||||
<div class="text-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>${_esc(dogName)}</strong> ist jetzt in Ban Yaro.
|
||||
<strong>${UI.escape(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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<button class="btn btn-primary" id="ob-diary-btn" style="width:100%">
|
||||
<div class="flex-col-gap-3">
|
||||
<button class="btn btn-primary" id="ob-diary-btn" class="w-full">
|
||||
<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" style="width:100%">
|
||||
<button class="btn btn-secondary" id="ob-profile-btn" class="w-full">
|
||||
<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(`${_esc(dog.name)} wurde angelegt!`);
|
||||
UI.toast.success(`${UI.escape(dog.name)} wurde angelegt!`);
|
||||
|
||||
_step = 3;
|
||||
_render();
|
||||
|
|
@ -452,9 +452,6 @@ window.Page_onboarding = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return UI.escape(s || '');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
|
|
|
|||
274
backend/static/js/pages/partner-profil.js
Normal file
274
backend/static/js/pages/partner-profil.js
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/* ============================================================
|
||||
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 1–2 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 };
|
||||
|
||||
})();
|
||||
149
backend/static/js/pages/partner.js
Normal file
149
backend/static/js/pages/partner.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/* ============================================================
|
||||
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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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 style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
<span class="text-xs-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 style="flex:1">
|
||||
<div class="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 style="padding:var(--space-4)">${scoreBars}</div>
|
||||
<div class="p-4">${scoreBars}</div>
|
||||
</div>
|
||||
|
||||
<!-- Teilen + Nochmal -->
|
||||
|
|
|
|||
|
|
@ -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 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>` : ''}
|
||||
${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>` : ''}
|
||||
${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" style="width:100%" id="place-detail-edit">Bearbeiten</button>
|
||||
<button type="button" class="btn btn-secondary w-full" 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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Adresse <span class="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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Website <span class="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 style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<label class="form-label">Telefon <span class="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" style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<div class="form-group flex-col-gap-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" style="width:100%">
|
||||
<button type="submit" form="place-form" class="btn btn-primary w-full">
|
||||
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-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>
|
||||
|
|
|
|||
|
|
@ -15,21 +15,16 @@ window.Page_playdate = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
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 = _esc((name || '?').charAt(0).toUpperCase());
|
||||
const initials = UI.escape((name || '?').charAt(0).toUpperCase());
|
||||
if (foto_url) {
|
||||
return `<img src="${_esc(foto_url)}" alt="${initials}"
|
||||
return `<img src="${UI.escape(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>'">`;
|
||||
}
|
||||
|
|
@ -86,7 +81,7 @@ window.Page_playdate = (() => {
|
|||
<div class="playdate-layout">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
|
||||
<div class="by-tabs" id="playdate-tabs" class="mb-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">
|
||||
|
|
@ -133,7 +128,7 @@ window.Page_playdate = (() => {
|
|||
<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 style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
|
||||
<span class="text-sm-secondary" id="nearby-location-label">
|
||||
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -245,34 +240,34 @@ window.Page_playdate = (() => {
|
|||
|
||||
function _nearbyCard(d) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div class="card p-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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
|
||||
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>` : ''}
|
||||
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>` : ''}
|
||||
</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 ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
|
||||
${d.ort_name ? UI.escape(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
|
||||
</span>
|
||||
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
|
||||
${d.geschlecht ? `<span class="text-xs-muted">${UI.escape(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">
|
||||
${_esc(d.beschreibung)}
|
||||
${UI.escape(d.beschreibung)}
|
||||
</p>` : ''}
|
||||
|
||||
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
|
||||
data-dog-id="${d.dog_id}"
|
||||
data-dog-name="${_esc(d.dog_name)}">
|
||||
data-dog-name="${UI.escape(d.dog_name)}">
|
||||
${UI.icon('paw-print')} Spielkamerad anfragen
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -389,12 +384,12 @@ window.Page_playdate = (() => {
|
|||
function _listingCard(dog, listing) {
|
||||
const isAktiv = listing && listing.aktiv;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div class="card p-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 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 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>
|
||||
<span style="font-size:var(--text-xs);font-weight:600;
|
||||
padding:2px 10px;border-radius:999px;
|
||||
|
|
@ -407,12 +402,12 @@ window.Page_playdate = (() => {
|
|||
${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 ? _esc(listing.ort_name) + ' · ' : ''}
|
||||
${listing.ort_name ? UI.escape(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">${_esc(listing.beschreibung)}</p>` : ''}
|
||||
margin:0 0 var(--space-3);line-height:1.5">${UI.escape(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.
|
||||
|
|
@ -442,10 +437,10 @@ window.Page_playdate = (() => {
|
|||
<form id="${formId}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ort / Standort</label>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-2">
|
||||
<input type="text" id="listing-ort" class="form-control"
|
||||
placeholder="z.B. München"
|
||||
value="${_esc(existing?.ort_name || '')}">
|
||||
value="${UI.escape(existing?.ort_name || '')}">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
|
||||
title="GPS-Standort ermitteln">
|
||||
${UI.icon('crosshair')}
|
||||
|
|
@ -472,7 +467,7 @@ window.Page_playdate = (() => {
|
|||
<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…">${_esc(existing?.beschreibung || '')}</textarea>
|
||||
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${UI.escape(existing?.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
|
|
@ -578,7 +573,7 @@ window.Page_playdate = (() => {
|
|||
<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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
${incoming.map(r => _incomingCard(r)).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
|
@ -588,7 +583,7 @@ window.Page_playdate = (() => {
|
|||
<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 style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="flex-col-gap-3">
|
||||
${outgoing.map(r => _outgoingCard(r)).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
|
@ -631,17 +626,17 @@ window.Page_playdate = (() => {
|
|||
function _incomingCard(r) {
|
||||
const isPending = r.status === 'pending';
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div class="card p-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 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 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>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
||||
<div class="text-xs-muted">${_fmtDate(r.created_at)}</div>
|
||||
</div>
|
||||
${_statusBadge(r.status)}
|
||||
</div>
|
||||
|
|
@ -651,11 +646,11 @@ window.Page_playdate = (() => {
|
|||
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">
|
||||
"${_esc(r.nachricht)}"
|
||||
"${UI.escape(r.nachricht)}"
|
||||
</div>` : ''}
|
||||
|
||||
${isPending ? `
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<div class="flex-gap-2">
|
||||
<button class="btn btn-primary btn-sm req-accept-btn"
|
||||
data-req-id="${r.id}" data-status="accepted">
|
||||
${UI.icon('check')} Annehmen
|
||||
|
|
@ -676,23 +671,23 @@ window.Page_playdate = (() => {
|
|||
|
||||
function _outgoingCard(r) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div class="card p-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 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 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>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
||||
<div class="text-xs-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)">
|
||||
"${_esc(r.nachricht)}"
|
||||
"${UI.escape(r.nachricht)}"
|
||||
</p>` : ''}
|
||||
|
||||
${r.status === 'accepted' ? `
|
||||
|
|
|
|||
|
|
@ -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" style="flex:1">
|
||||
<button class="btn btn-secondary" id="poison-btn-erstehilfe" class="flex-1">
|
||||
${UI.icon('first-aid')} Erste Hilfe & Tiergift
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -94,8 +94,7 @@ window.Page_poison = (() => {
|
|||
document.getElementById('poison-btn-erstehilfe')
|
||||
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
|
||||
|
||||
await UI.loadLeaflet();
|
||||
_initMap();
|
||||
await _initMap();
|
||||
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen
|
||||
setTimeout(() => _map?.invalidateSize(), 100);
|
||||
await _locateAndLoad();
|
||||
|
|
@ -104,17 +103,16 @@ window.Page_poison = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// KARTE INITIALISIEREN
|
||||
// ----------------------------------------------------------
|
||||
function _initMap() {
|
||||
async function _initMap() {
|
||||
const mapEl = document.getElementById('poison-map');
|
||||
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);
|
||||
if (!mapEl || _map) return;
|
||||
|
||||
_map = await UI.map.create('poison-map', {
|
||||
center: [51.1657, 10.4515], // Deutschland-Mitte
|
||||
zoom: 6,
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -221,7 +219,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" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</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>' : ''}
|
||||
`);
|
||||
|
||||
marker.on('click', () => _openDetail(r));
|
||||
|
|
@ -276,13 +274,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 style="flex:1;min-width:0">
|
||||
<div class="flex-1-min">
|
||||
<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" style="color:var(--c-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" class="text-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">
|
||||
|
|
@ -295,7 +293,7 @@ window.Page_poison = (() => {
|
|||
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
|
||||
</p>`
|
||||
: ''}
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
<div class="text-xs-secondary">
|
||||
Gemeldet ${_fmtDate(r.created_at)} ·
|
||||
läuft ab ${_fmtDate(r.expires_at)}
|
||||
</div>
|
||||
|
|
@ -336,7 +334,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" style="color:var(--c-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" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
|
||||
</div>
|
||||
|
||||
${r.beschreibung
|
||||
|
|
@ -353,7 +351,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" 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-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-show-map">🗺️ Auf Karte</button>
|
||||
${isOwnEntry || isAdmin
|
||||
|
|
@ -472,7 +470,7 @@ window.Page_poison = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Beschreibung
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="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>
|
||||
|
|
@ -481,7 +479,7 @@ window.Page_poison = (() => {
|
|||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Foto
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
<span class="text-secondary">(optional)</span>
|
||||
</label>
|
||||
<input class="form-control" type="file" name="photo"
|
||||
accept="image/*" capture="environment">
|
||||
|
|
@ -593,7 +591,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 style="margin-bottom:var(--space-4)">
|
||||
<div class="mb-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
Loading…
Add table
Add a link
Reference in a new issue