Release v1.3.0

This commit is contained in:
rene 2026-05-03 11:09:43 +02:00
commit 15e2446ea7
68 changed files with 16373 additions and 465 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?",
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,)
).fetchone()
@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
def require_social_media(user=Depends(get_current_user)):
"""Dependency: Social-Media-Manager oder Admin."""
if not (user.get("is_social_media") or user["rolle"] == "admin"):
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user

63
backend/content_filter.py Normal file
View file

@ -0,0 +1,63 @@
"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum."""
import re
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
# Offensichtliche Spam-Signale
_SPAM_KEYWORDS = [
"casino", "poker", "slots", "jackpot", "sportwetten",
"viagra", "cialis", "levitra", "pharmacy", "apotheke online",
"kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen",
"passive income", "work from home", "earn money fast",
"click here", "klick hier", "free followers", "buy followers",
"whatsapp +", "telegram +", "call now", "jetzt anrufen",
"seo service", "backlinks kaufen", "website traffic",
"crypto invest", "bitcoin verdienen", "nft mint",
"lose weight fast", "abnehmen schnell", "diät pille",
]
# URL-Muster (http/https oder nackte Domains)
_URL_RE = re.compile(
r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)",
re.IGNORECASE,
)
# Mindest-Account-Alter für URL-Posts (Tage)
_MIN_DAYS_FOR_URLS = 7
def check_forum_content(text: str, user_created_at: str | None = None) -> None:
"""
Prüft Forum-Text auf Spam.
Wirft HTTPException(400) bei Fund.
"""
lower = text.lower()
# Spam-Keywords
for kw in _SPAM_KEYWORDS:
if kw in lower:
raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.")
# URLs in neuen Accounts sperren
if _URL_RE.search(text):
if user_created_at:
try:
created = datetime.fromisoformat(user_created_at)
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created
if age < timedelta(days=_MIN_DAYS_FOR_URLS):
raise HTTPException(
400,
"Links können erst nach 7 Tagen Mitgliedschaft gepostet werden."
)
except (ValueError, TypeError):
pass
# Zu viele Sonderzeichen / Zeichensalat
if len(text) > 20:
alnum = sum(c.isalnum() or c.isspace() for c in text)
ratio = alnum / len(text)
if ratio < 0.5:
raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.")

View file

@ -701,7 +701,28 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
""")
# Hunde-Filme: Bewertungen + Hund des Monats
# Hunde-Filme: Katalog + Bewertungen + Hund des Monats
conn.executescript("""
CREATE TABLE IF NOT EXISTS movies (
id TEXT PRIMARY KEY,
titel TEXT NOT NULL,
originaltitel TEXT,
jahr INTEGER,
genre TEXT,
typ TEXT NOT NULL DEFAULT 'film',
hund_rasse TEXT,
stirbt_der_hund INTEGER NOT NULL DEFAULT 0,
beschreibung TEXT,
bild_emoji TEXT DEFAULT '🐾',
imdb_rating REAL,
streaming TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ);
CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC);
""")
conn.executescript("""
CREATE TABLE IF NOT EXISTS movie_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -1051,6 +1072,19 @@ def _migrate(conn_factory):
pass
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
# Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter
for table, col, typedef in [
("forum_reports", "resolved_by", "INTEGER"),
("forum_reports", "resolved_at", "TEXT"),
("wiki_zuchter", "verified_by", "INTEGER"),
("wiki_zuchter", "verified_at", "TEXT"),
("wiki_zuchter", "reject_reason", "TEXT"),
]:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
except Exception:
pass
# Wiki: Züchter-Verzeichnis
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_zuchter (
@ -1561,6 +1595,35 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
# Job-Bewerbungen + Luna-Probezugang
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
dog_name TEXT,
dog_rasse TEXT,
social_handle TEXT,
motivation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC);
CREATE TABLE IF NOT EXISTS job_application_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'luna_trial_until' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT")
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te:
@ -1581,3 +1644,299 @@ def _migrate(conn_factory):
conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id']))
conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)")
logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.")
# Hund des Monats — dauerhafte Gewinner-Tabelle
conn.executescript("""
CREATE TABLE IF NOT EXISTS hund_des_monats_wins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
monat TEXT NOT NULL,
stimmen INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id, monat)
);
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
""")
# Trainings-Streak-Tabelle
conn.execute("""
CREATE TABLE IF NOT EXISTS training_streaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
current_streak INTEGER NOT NULL DEFAULT 0,
longest_streak INTEGER NOT NULL DEFAULT 0,
last_training_date TEXT,
UNIQUE(user_id, dog_id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)")
# Ausgaben-Tracker
conn.executescript("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
kategorie TEXT NOT NULL,
betrag REAL NOT NULL,
datum TEXT NOT NULL,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC);
""")
# KI-Tierarztfragen Rate-Limit-Log
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_tierarzt_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User)
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_rasse_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user
ON ki_rasse_log(user_id, created_at DESC)
""")
# feed_recalls — Rückruf-Alarm für Tierfutter (RASFF)
conn.execute("""
CREATE TABLE IF NOT EXISTS feed_recalls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL UNIQUE,
titel TEXT NOT NULL,
produkt TEXT,
gefahr TEXT,
herkunft TEXT,
datum TEXT NOT NULL,
quelle TEXT NOT NULL DEFAULT 'rasff',
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)")
# Adoption-Cache
conn.execute("""
CREATE TABLE IF NOT EXISTS adoption_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
rasse TEXT,
alter_jahre REAL,
geschlecht TEXT,
foto_url TEXT,
tierheim TEXT,
tierheim_plz TEXT,
tierheim_lat REAL,
tierheim_lon REAL,
adoptions_url TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS community_adoption (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
name TEXT NOT NULL,
rasse TEXT,
alter_jahre REAL,
geschlecht TEXT,
foto_url TEXT,
beschreibung TEXT NOT NULL,
gruende TEXT,
ort TEXT,
plz TEXT,
lat REAL,
lon REAL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS community_adoption_interest (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nachricht TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(listing_id, user_id)
)
""")
# ---- Wetter-Log (historische Vorhersage-Daten) ----
conn.execute("""
CREATE TABLE IF NOT EXISTS weather_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
date TEXT NOT NULL,
lat_r REAL NOT NULL,
lon_r REAL NOT NULL,
temp_max REAL,
temp_min REAL,
feels_max REAL,
precip_prob INTEGER,
precip_sum REAL,
wind_kmh REAL,
wind_dir TEXT,
uv_index REAL,
weathercode INTEGER,
weatherdesc TEXT,
sunrise TEXT,
sunset TEXT,
asphalt_temp REAL,
asphalt_warn TEXT,
zecken TEXT,
pollen_erle INTEGER,
pollen_birke INTEGER,
pollen_graeser INTEGER,
pollen_beifuss INTEGER,
pollen_ambrosia INTEGER,
forecast_json TEXT,
UNIQUE(date, lat_r, lon_r)
)
""")
# ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
conn.execute("""
CREATE TABLE IF NOT EXISTS favorite_vets (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, vet_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS health_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
typ TEXT NOT NULL,
titel TEXT NOT NULL,
beschreibung TEXT,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL,
datum TEXT,
vet_id INTEGER REFERENCES tieraerzte(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)")
# Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS vaccinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
krankheit TEXT NOT NULL,
datum TEXT NOT NULL,
naechste TEXT,
tierarzt TEXT,
charge_nr TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS medications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
dosierung TEXT,
von TEXT,
bis TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS dog_passport_meta (
dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE,
blutgruppe TEXT,
allergien TEXT,
besonderheiten TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS passport_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
valid_until TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token)
""")
logger.info("Migration: Hundepass-Tabellen bereit.")
except Exception as e:
logger.warning(f"Migration Hundepass: {e}")
# ---- Playdate ----
conn.execute("""
CREATE TABLE IF NOT EXISTS playdate_listings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lat REAL NOT NULL,
lon REAL NOT NULL,
ort_name TEXT,
radius_km INTEGER NOT NULL DEFAULT 10,
beschreibung TEXT,
aktiv INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo
ON playdate_listings(lat, lon) WHERE aktiv=1
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS playdate_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
nachricht TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(from_dog_id, to_dog_id)
)
""")
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
kategorie TEXT NOT NULL,
betrag REAL NOT NULL,
haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich
startdatum TEXT NOT NULL,
naechste_faelligkeit TEXT NOT NULL,
notiz TEXT,
aktiv INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")

View file

@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}")
def email_html(
body_html: str,
cta_url: str = None,
cta_label: str = None,
footer_text: str = None,
) -> str:
"""Shared branded HTML email template (matches Status-Report design)."""
cta_block = ""
if cta_url and cta_label:
cta_block = f"""
<p style="margin:24px 0 0">
<a href="{cta_url}"
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
{cta_label}
</a>
</p>"""
footer = footer_text or "Ban Yaro · banyaro.app"
return f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro</div>
</div>
<div style="padding:28px;color:#333;font-size:15px;line-height:1.6">
{body_html}{cta_block}
</div>
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
{footer}
</div>
</div>
</body>
</html>"""
async def send_verify_email(to: str, name: str, token: str):
url = f"{APP_URL}/api/auth/verify/{token}"
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
html = f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"></head>
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:0">
<div style="max-width:520px;margin:32px auto;background:#fff;border-radius:12px;
padding:40px 32px;box-shadow:0 2px 8px rgba(0,0,0,.08)">
<h1 style="color:#C4843A;margin:0 0 8px;font-size:24px">Ban Yaro 🐾</h1>
<p style="color:#444;margin:0 0 24px">Hallo {name},</p>
<p style="color:#444;margin:0 0 24px">
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
</p>
<p style="margin:0 0 32px">
<a href="{url}"
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
E-Mail bestätigen
</a>
</p>
<p style="color:#888;font-size:13px;margin:0 0 8px">
Der Link ist 48 Stunden gültig.
</p>
<p style="color:#bbb;font-size:12px;margin:0">
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>
</div>
</body>
</html>"""
</p>"""
plain = (
f"Ban Yaro — E-Mail-Adresse bestätigen\n\n"
f"Hallo {name},\n\n"
f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n"
f"Der Link ist 48 Stunden gültig.\n"
)
html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n"
await send_email(to, subject, html, plain)

View file

@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
from contextlib import asynccontextmanager
from database import init_db
@ -46,6 +47,8 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
sched.start()
yield
@ -67,11 +70,20 @@ app = FastAPI(
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
@ -123,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
return response
app.add_middleware(MediaCacheMiddleware)
app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
app.add_middleware(GZipMiddleware, minimum_size=1000)
@ -177,6 +190,14 @@ from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
from routes.streak import router as streak_router
from routes.expenses import router as expenses_router
from routes.recalls import router as recalls_router
from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -210,6 +231,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"])
@ -227,6 +249,13 @@ app.include_router(training_router, prefix="/api/training", tags=
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
app.include_router(streak_router, prefix="/api", tags=["Streak"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
# ------------------------------------------------------------------
@ -1416,6 +1445,13 @@ async def knigge_page():
# ------------------------------------------------------------------
# /presse — Presseseite
# ------------------------------------------------------------------
@app.get("/presse")
async def presse():
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")
@ -1617,6 +1653,189 @@ async def partner_landing():
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
# ------------------------------------------------------------------
# Honeypot-Fallen für Scanner und Bots
# Jeder Aufruf → 24h IP-Sperre
# ------------------------------------------------------------------
from ratelimit import block_ip as _block_ip
_HONEYPOT_PATHS = [
"/api/admin/users",
"/api/v1/users",
"/api/users",
"/api/.env",
"/api/config",
"/api/setup",
"/api/install",
"/api/phpinfo",
"/api/debug",
"/api/actuator",
"/api/actuator/health",
"/api/swagger",
"/api/graphql",
]
async def _honeypot_handler(request: Request):
import logging as _log
_log.getLogger("banyaro.security").warning(
"Honeypot getroffen: %s %s — IP %s",
request.method, request.url.path,
request.client.host if request.client else "?"
)
_block_ip(request, hours=24)
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "Not Found"})
for _hp in _HONEYPOT_PATHS:
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
# ------------------------------------------------------------------
# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
# ------------------------------------------------------------------
@app.get("/pass/{token}")
async def passport_share_page(token: str):
from fastapi.responses import HTMLResponse
from database import db as _db
from datetime import date as _date
with _db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link nicht gefunden</h2><p>Dieser Hundepass-Link ist ungültig.</p>',
status_code=404
)
if share["valid_until"] < _date.today().isoformat():
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link abgelaufen</h2><p>Dieser Hundepass-Link ist nicht mehr gültig.</p>',
status_code=410
)
dog_id = share["dog_id"]
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
vaccs = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
meds = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
def _fmt(d):
if not d:
return ""
try:
from datetime import datetime as _dt
return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
dog = dict(dog)
meta = dict(meta) if meta else {}
vaccs = [dict(v) for v in vaccs]
meds = [dict(m) for m in meds]
_g_map = {"m": "Rüde", "w": "Hündin"}
vacc_rows = "".join(f"""
<tr>
<td>{v['krankheit'] or ''}</td>
<td>{_fmt(v['datum'])}</td>
<td>{_fmt(v['naechste'])}</td>
<td>{v['tierarzt'] or ''}</td>
<td>{v['charge_nr'] or ''}</td>
</tr>""" for v in vaccs) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
med_rows = "".join(f"""
<tr>
<td>{m['name'] or ''}</td>
<td>{m['dosierung'] or ''}</td>
<td>{_fmt(m['von'])}</td>
<td>{_fmt(m['bis']) if m['bis'] else 'dauerhaft'}</td>
<td>{m['notiz'] or ''}</td>
</tr>""" for m in meds) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hundepass {dog['name']}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7f5; color: #222; }}
.header {{ background: #28a764; color: #fff; padding: 24px 20px; text-align: center; }}
.header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
.header p {{ font-size: 0.9rem; opacity: 0.85; }}
.container {{ max-width: 760px; margin: 24px auto; padding: 0 16px; }}
.card {{ background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); }}
.card h2 {{ font-size: 1rem; color: #28a764; margin-bottom: 14px; display: flex;
align-items: center; gap: 8px; border-bottom: 1px solid #e8f5ee; padding-bottom: 10px; }}
.info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
.info-item label {{ font-size: 0.75rem; color: #888; display: block; margin-bottom: 2px; }}
.info-item span {{ font-size: 0.9rem; font-weight: 500; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.85rem; }}
th {{ background: #e8f5ee; text-align: left; padding: 8px; font-size: 0.8rem;
color: #444; font-weight: 600; }}
td {{ padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.footer {{ text-align: center; font-size: 0.75rem; color: #aaa; margin: 24px 0; }}
@media (max-width: 500px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="header">
<h1>Ban Yaro</h1>
<p>Digitaler Hundepass &mdash; {dog['name']}</p>
</div>
<div class="container">
<div class="card">
<h2>Hundeangaben</h2>
<div class="info-grid">
<div class="info-item"><label>Name</label><span>{dog['name']}</span></div>
<div class="info-item"><label>Rasse</label><span>{dog.get('rasse') or ''}</span></div>
<div class="info-item"><label>Geburtstag</label><span>{_fmt(dog.get('geburtstag'))}</span></div>
<div class="info-item"><label>Geschlecht</label><span>{_g_map.get(dog.get('geschlecht',''), '')}</span></div>
<div class="info-item"><label>Chip-Nr.</label><span>{dog.get('chip_nr') or ''}</span></div>
<div class="info-item"><label>Blutgruppe</label><span>{meta.get('blutgruppe') or ''}</span></div>
</div>
{('<div style="margin-top:14px"><label style="font-size:.75rem;color:#888">Allergien</label>'
f'<div style="font-size:.9rem">{meta["allergien"]}</div></div>') if meta.get("allergien") else ''}
{('<div style="margin-top:10px"><label style="font-size:.75rem;color:#888">Besonderheiten</label>'
f'<div style="font-size:.9rem">{meta["besonderheiten"]}</div></div>') if meta.get("besonderheiten") else ''}
</div>
<div class="card">
<h2>Impfungen</h2>
<table>
<thead><tr>
<th>Krankheit</th><th>Datum</th><th>Nächste</th><th>Tierarzt</th><th>Charge</th>
</tr></thead>
<tbody>{vacc_rows}</tbody>
</table>
</div>
<div class="card">
<h2>Medikamente</h2>
<table>
<thead><tr>
<th>Medikament</th><th>Dosierung</th><th>Von</th><th>Bis</th><th>Notiz</th>
</tr></thead>
<tbody>{med_rows}</tbody>
</table>
</div>
</div>
<div class="footer">Erstellt mit Ban Yaro &mdash; banyaro.app</div>
</body>
</html>"""
return HTMLResponse(html)
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

View file

@ -1,9 +1,9 @@
"""
BAN YARO Rate Limiter + IP-Blocklist
BAN YARO Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
Blocklist für Honeypot-Treffer.
"""
import hashlib
import threading
from collections import defaultdict, deque
from datetime import datetime, timedelta
@ -11,18 +11,23 @@ from datetime import datetime, timedelta
from fastapi import HTTPException, Request
_buckets: dict[str, deque] = defaultdict(deque)
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...]
_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime}
_lock = threading.Lock()
_LOCKOUT_WINDOW = 15 # Minuten
_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre
_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten)
# ------------------------------------------------------------------
# IP-basiertes Rate Limiting
# ------------------------------------------------------------------
def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""):
"""
Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
"""
"""Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten."""
ip = (request.client.host if request.client else "unknown")
# Blocklist prüfen
with _lock:
blocked_until = _blocklist.get(ip)
if blocked_until and datetime.utcnow() < blocked_until:
@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool:
elif until:
del _blocklist[ip]
return False
# ------------------------------------------------------------------
# Account-Lockout (per E-Mail)
# ------------------------------------------------------------------
def record_login_failure(email: str) -> int:
"""Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück."""
email = email.lower()
now = datetime.utcnow()
cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
with _lock:
recent = [t for t in _login_failures[email] if t > cutoff]
recent.append(now)
_login_failures[email] = recent
return len(recent)
def is_account_locked(email: str) -> bool:
"""True wenn ≥5 Fehlversuche in den letzten 15 Minuten."""
email = email.lower()
now = datetime.utcnow()
cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
with _lock:
recent = [t for t in _login_failures.get(email, []) if t > cutoff]
return len(recent) >= _LOCKOUT_ATTEMPTS
def clear_login_failures(email: str):
"""Bei erfolgreichem Login zurücksetzen."""
with _lock:
_login_failures.pop(email.lower(), None)
# ------------------------------------------------------------------
# Duplikat-Post-Erkennung (per User, in-memory)
# ------------------------------------------------------------------
def content_hash(text: str) -> str:
normalized = " ".join(text.lower().split())
return hashlib.sha256(normalized.encode()).hexdigest()[:20]
def is_duplicate_post(user_id: int, text: str) -> bool:
"""True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat."""
h = content_hash(text)
now = datetime.utcnow()
cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW)
with _lock:
hashes = _post_hashes[user_id]
# Alte Einträge bereinigen
expired = [k for k, ts in hashes.items() if ts < cutoff]
for k in expired:
del hashes[k]
return h in hashes
def record_post(user_id: int, text: str):
"""Post-Hash speichern nach erfolgreichem Erstellen."""
h = content_hash(text)
with _lock:
_post_hashes[user_id][h] = datetime.utcnow()

View file

@ -13,3 +13,6 @@ pywebpush==2.0.0
apscheduler==3.10.4
odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3
python-dateutil>=2.9
brotli-asgi==1.4.0

View file

@ -97,6 +97,40 @@ class ThreadAdminPatch(BaseModel):
is_deleted: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/admin/action-items
# ------------------------------------------------------------------
@router.get("/action-items")
async def action_items(user=Depends(require_mod)):
with db() as conn:
jobs = conn.execute(
"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')"
).fetchone()[0]
breeders = conn.execute(
"SELECT COUNT(*) FROM users WHERE breeder_status='pending'"
).fetchone()[0]
reports = conn.execute(
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
).fetchone()[0]
fotos = conn.execute(
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
).fetchone()[0]
poi_edits = conn.execute(
"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
).fetchone()[0]
users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0]
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
"reports_open": reports,
"fotos_pending": fotos,
"poi_edits_pending": poi_edits,
"users_today": users_today,
}
# ------------------------------------------------------------------
# GET /api/admin/stats
# ------------------------------------------------------------------
@ -322,11 +356,15 @@ async def list_users(
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
# Rollenwechsel nur für Admins
# Rollenwechsel + Privileg-Flags nur für Admins
if data.rolle is not None and user["rolle"] != "admin":
raise HTTPException(403, "Rollenwechsel nur für Admins.")
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
raise HTTPException(400, "Ungültige Rolle.")
if data.is_moderator is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
if data.is_social_media is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
with db() as conn:
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()

547
backend/routes/adoption.py Normal file
View file

@ -0,0 +1,547 @@
"""
BAN YARO Adoption (Tierheim-Hunde in der Nähe)
Strategie:
1. PetFinder API (falls API-Key gesetzt) hat kaum deutsche Tierheime, nur als Bonus
2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten
3. Fallback: Weiterleitung zu tierheimhelden.de
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 typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
logger = logging.getLogger(__name__)
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)
# ------------------------------------------------------------------
GERMAN_SHELTERS = [
# (id, name, plz, stadt, lat, lon, url)
("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"),
("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"),
("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"),
("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"),
("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"),
("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"),
("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"),
("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"),
("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"),
("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"),
("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"),
("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"),
("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"),
("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"),
("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"),
("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"),
("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"),
("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"),
("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"),
("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"),
("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"),
("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"),
("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"),
("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"),
("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"),
]
# ------------------------------------------------------------------
# PetFinder OAuth2 Token
# ------------------------------------------------------------------
_pf_token = None
_pf_token_exp = 0.0
async def _get_pf_token() -> str | None:
global _pf_token, _pf_token_exp
if not (PETFINDER_KEY and PETFINDER_SECRET):
return None
now = asyncio.get_event_loop().time()
if _pf_token and now < _pf_token_exp - 60:
return _pf_token
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
"https://api.petfinder.com/v2/oauth2/token",
data={"grant_type": "client_credentials",
"client_id": PETFINDER_KEY,
"client_secret": PETFINDER_SECRET},
)
if r.status_code == 200:
data = r.json()
_pf_token = data.get("access_token")
_pf_token_exp = now + data.get("expires_in", 3600)
return _pf_token
except Exception as e:
logger.warning(f"PetFinder OAuth: {e}")
return None
# ------------------------------------------------------------------
# PetFinder: Hunde in der Nähe holen
# ------------------------------------------------------------------
async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]:
token = await _get_pf_token()
if not token:
return []
try:
async with httpx.AsyncClient(timeout=12) as client:
r = await client.get(
"https://api.petfinder.com/v2/animals",
headers={"Authorization": f"Bearer {token}"},
params={
"type": "dog",
"location": f"{lat},{lon}",
"distance": radius,
"limit": 20,
"sort": "distance",
"status": "adoptable",
},
)
if r.status_code != 200:
logger.warning(f"PetFinder API: HTTP {r.status_code}")
return []
animals = r.json().get("animals", [])
result = []
for a in animals:
org = a.get("organization_id", "")
loc = a.get("contact", {}).get("address", {})
photos = a.get("photos", [])
foto = photos[0].get("medium") if photos else None
age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0}
result.append({
"external_id": f"pf_{a['id']}",
"name": a.get("name", "Unbekannt"),
"rasse": ", ".join(
filter(None, [
a.get("breeds", {}).get("primary"),
a.get("breeds", {}).get("secondary"),
])
) or None,
"alter_jahre": age_map.get(a.get("age"), None),
"geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None),
"foto_url": foto,
"tierheim": org,
"tierheim_plz": loc.get("postcode"),
"tierheim_lat": None,
"tierheim_lon": None,
"adoptions_url": a.get("url", "https://www.petfinder.com/"),
"quelle": "petfinder",
})
return result
except Exception as e:
logger.warning(f"PetFinder Fetch: {e}")
return []
# ------------------------------------------------------------------
# Cache befüllen
# ------------------------------------------------------------------
async def _refresh_cache(lat: float, lon: float, radius: int):
"""Holt frische Daten und schreibt sie in adoption_cache."""
animals = await _fetch_petfinder(lat, lon, radius)
if not animals:
return
expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
with db() as conn:
for a in animals:
try:
conn.execute("""
INSERT INTO adoption_cache
(external_id, name, rasse, alter_jahre, geschlecht,
foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon,
adoptions_url, expires_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(external_id) DO UPDATE SET
name=excluded.name,
rasse=excluded.rasse,
alter_jahre=excluded.alter_jahre,
geschlecht=excluded.geschlecht,
foto_url=excluded.foto_url,
tierheim=excluded.tierheim,
tierheim_plz=excluded.tierheim_plz,
tierheim_lat=excluded.tierheim_lat,
tierheim_lon=excluded.tierheim_lon,
adoptions_url=excluded.adoptions_url,
expires_at=excluded.expires_at
""", (
a["external_id"], a["name"], a["rasse"], a["alter_jahre"],
a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"],
a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires,
))
except Exception as e:
logger.warning(f"Cache insert: {e}")
# ------------------------------------------------------------------
# GET /api/adoption/nearby
# ------------------------------------------------------------------
@router.get("/nearby")
async def adoption_nearby(
lat: float = Query(..., description="Breitengrad"),
lon: float = Query(..., description="Längengrad"),
radius: int = Query(50, ge=5, le=200, description="Radius in km"),
background_tasks: BackgroundTasks = None,
):
"""
Gibt Adoptionshunde in der Nähe zurück.
Priorisierung:
1. Frische PetFinder-Einträge aus Cache
2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung)
"""
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# ------ Cache lesen ------
cached_animals = []
with db() as conn:
rows = conn.execute("""
SELECT * FROM adoption_cache
WHERE expires_at > ?
ORDER BY created_at DESC
""", (now_str,)).fetchall()
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"])
if dist <= radius:
d["distanz_km"] = round(dist, 1)
cached_animals.append(d)
else:
# PetFinder-Einträge ohne Koordinaten: immer anzeigen
d["distanz_km"] = None
cached_animals.append(d)
# ------ Cache refreshen wenn leer oder alt ------
if not cached_animals and background_tasks is not None:
background_tasks.add_task(_refresh_cache, lat, lon, radius)
# ------ Statische Tierheime (immer) ------
shelters = []
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
dist = _haversine(lat, lon, slat, slon)
if dist <= radius:
shelters.append({
"id": sid,
"name": name,
"plz": plz,
"stadt": stadt,
"lat": slat,
"lon": slon,
"url": url,
"distanz_km": round(dist, 1),
})
shelters.sort(key=lambda x: x["distanz_km"])
return {
"animals": cached_animals[:40],
"shelters": shelters[:10],
"has_petfinder": bool(PETFINDER_KEY),
}
# ------------------------------------------------------------------
# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim
# ------------------------------------------------------------------
@router.get("/geocode")
async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
"""Wandelt eine PLZ in Koordinaten um (via Nominatim)."""
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": f"{plz}, Germany",
"format": "json",
"limit": 1,
"accept-language": "de",
"countrycodes": "de",
},
headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"},
)
results = r.json()
if results:
return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)}
except Exception as e:
logger.warning(f"Geocode PLZ {plz}: {e}")
return {"lat": None, "lon": None, "display": plz}
# ==================================================================
# Community Adoption — Privates Weitervermittlungs-Board
# ==================================================================
class InterestBody(BaseModel):
nachricht: Optional[str] = None
# ------------------------------------------------------------------
# GET /api/adoption/community/my — eigene Inserate
# ------------------------------------------------------------------
@router.get("/community/my")
def community_my(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute("""
SELECT ca.*,
u.name AS besitzer_name,
(SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count
FROM community_adoption ca
JOIN users u ON u.id = ca.user_id
WHERE ca.user_id = ? AND ca.status != 'deleted'
ORDER BY ca.created_at DESC
""", (user["id"],)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe)
# ------------------------------------------------------------------
@router.get("/community")
def community_list(
lat: Optional[float] = Query(None),
lon: Optional[float] = Query(None),
radius: float = Query(200.0, description="Radius in km (default 200)"),
user=Depends(get_current_user),
):
with db() as conn:
rows = conn.execute("""
SELECT ca.*,
u.name AS besitzer_name,
(SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count,
(SELECT COUNT(*) FROM community_adoption_interest i2
WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested
FROM community_adoption ca
JOIN users u ON u.id = ca.user_id
WHERE ca.status = 'active'
ORDER BY ca.created_at DESC
LIMIT 50
""", (user["id"],)).fetchall()
result = []
for row in rows:
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"])
d["distanz_km"] = round(dist, 1)
if dist > radius:
continue
else:
d["distanz_km"] = None
result.append(d)
if lat is not None and lon is not None:
result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999)
return result
# ------------------------------------------------------------------
# POST /api/adoption/community — Inserat erstellen
# ------------------------------------------------------------------
@router.post("/community", status_code=201)
async def community_create(
name: str = Form(...),
beschreibung: str = Form(...),
rasse: str = Form(""),
alter_jahre: Optional[float] = Form(None),
geschlecht: str = Form(""),
gruende: str = Form(""),
ort: str = Form(""),
plz: str = Form(""),
lat: Optional[float] = Form(None),
lon: Optional[float] = Form(None),
dog_id: Optional[int] = Form(None),
foto: Optional[UploadFile] = File(None),
user=Depends(get_current_user),
):
foto_url = None
if foto and foto.filename:
MAX_SIZE = 5 * 1024 * 1024
header = await foto.read(12)
if len(header) < 3:
raise HTTPException(400, "Ungültige Datei")
is_jpeg = header[:3] == b"\xff\xd8\xff"
is_png = header[:4] == b"\x89PNG"
is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP"
if not (is_jpeg or is_png or is_webp):
raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt")
rest = await foto.read(MAX_SIZE)
if len(rest) >= MAX_SIZE:
raise HTTPException(400, "Foto zu groß (max 5 MB)")
data = header + rest
folder = os.path.join(MEDIA_DIR, "adoption")
os.makedirs(folder, exist_ok=True)
filename = f"{uuid.uuid4()}.jpg"
filepath = os.path.join(folder, filename)
with open(filepath, "wb") as f:
f.write(data)
foto_url = f"/media/adoption/{filename}"
with db() as conn:
cur = conn.execute("""
INSERT INTO community_adoption
(user_id, dog_id, name, rasse, alter_jahre, geschlecht,
foto_url, beschreibung, gruende, ort, plz, lat, lon)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
user["id"], dog_id, name, rasse or None, alter_jahre,
geschlecht or None, foto_url, beschreibung,
gruende or None, ort or None, plz or None, lat, lon,
))
new_id = cur.lastrowid
row = conn.execute(
"SELECT * FROM community_adoption WHERE id = ?", (new_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------
class _StatusBody(BaseModel):
status: str
@router.patch("/community/{listing_id}")
def community_update_status(
listing_id: int,
body: _StatusBody,
user=Depends(get_current_user),
):
allowed = {"active", "reserved", "vermittelt"}
if body.status not in allowed:
raise HTTPException(400, f"Status muss einer von {allowed} sein")
status = body.status
with db() as conn:
cur = conn.execute("""
UPDATE community_adoption
SET status = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
""", (status, listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer)
# ------------------------------------------------------------------
@router.delete("/community/{listing_id}")
def community_delete(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
cur = conn.execute("""
UPDATE community_adoption
SET status = 'deleted', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
""", (listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/adoption/community/{id}/interest — Interesse bekunden
# ------------------------------------------------------------------
@router.post("/community/{listing_id}/interest", status_code=201)
def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)):
nachricht = (body.nachricht if body else None) or None
with db() as conn:
listing = conn.execute(
"SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
(listing_id,)
).fetchone()
if not listing:
raise HTTPException(404, "Inserat nicht gefunden")
if listing["user_id"] == user["id"]:
raise HTTPException(400, "Eigenes Inserat")
try:
conn.execute("""
INSERT INTO community_adoption_interest (listing_id, user_id, nachricht)
VALUES (?, ?, ?)
""", (listing_id, user["id"], nachricht))
except Exception:
raise HTTPException(409, "Interesse bereits bekundet")
try:
send_push_to_user(listing["user_id"], {
"title": "Jemand interessiert sich für deinen Hund \U0001f43e",
"body": f"{user['name']} möchte mehr über {listing['name']} erfahren.",
"url": "/#adoption",
})
except Exception as e:
logger.warning(f"Push interest: {e}")
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen
# ------------------------------------------------------------------
@router.delete("/community/{listing_id}/interest")
def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
cur = conn.execute("""
DELETE FROM community_adoption_interest
WHERE listing_id = ? AND user_id = ?
""", (listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Kein Interesse gefunden")
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer)
# ------------------------------------------------------------------
@router.get("/community/{listing_id}/interests")
def community_interests(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
listing = conn.execute(
"SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
(listing_id,)
).fetchone()
if not listing:
raise HTTPException(404, "Inserat nicht gefunden")
if listing["user_id"] != user["id"]:
raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen")
rows = conn.execute("""
SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url
FROM community_adoption_interest i
JOIN users u ON u.id = i.user_id
WHERE i.listing_id = ?
ORDER BY i.created_at ASC
""", (listing_id,)).fetchall()
return [dict(r) for r in rows]

View file

@ -15,7 +15,7 @@ from auth import (
get_current_user
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
router = APIRouter()
COOKIE_NAME = "by_token"
@ -26,18 +26,25 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
import html as _html
from routes.outreach import _send_smtp
from mailer import email_html
url = f"{_APP_URL}/api/auth/verify-email/{token}"
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
body = (
f"Hallo {name},\n\n"
"willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
"Der Link ist 7 Tage gültig.\n\n"
"Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
"Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
)
_ename = _html.escape(name)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen")
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
_send_smtp(email, subject, body, "support")
_send_smtp(email, subject, plain, "support", html=html)
except Exception:
pass # Nicht blockieren wenn SMTP fehlschlägt
@ -139,24 +146,32 @@ async def register(data: RegisterRequest, response: Response, request: Request):
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
_send_verification_email(data.email, name, verify_token)
return {"token": token, "name": name, "email_verified": 0}
return {"pending_verification": True}
@router.post("/login")
async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
if is_account_locked(data.email):
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
with db() as conn:
user = conn.execute(
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
"SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?",
(data.email,)
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
if not user["email_verified"]:
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
@ -249,23 +264,24 @@ async def verify_email(token: str):
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
class ResendVerificationRequest(BaseModel):
email: EmailStr
@router.post("/resend-verification")
async def resend_verification(request: Request, user=Depends(get_current_user)):
rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
async def resend_verification(data: ResendVerificationRequest, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}")
with db() as conn:
row = conn.execute(
"SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],)
"SELECT id, name, email_verified FROM users WHERE email=?", (data.email,)
).fetchone()
if not row:
raise HTTPException(404)
if row["email_verified"]:
return {"ok": True, "already_verified": True}
if not row or row["email_verified"]:
return {"ok": True}
token = secrets.token_urlsafe(32)
with db() as conn:
conn.execute(
"UPDATE users SET verification_token=? WHERE id=?", (token, user["id"])
"UPDATE users SET verification_token=? WHERE id=?", (token, row["id"])
)
_send_verification_email(row["email"], row["name"], token)
_send_verification_email(data.email, row["name"], token)
return {"ok": True}
@ -292,19 +308,26 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
import html as _html
app_url = os.getenv("APP_URL", "https://banyaro.app")
url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
body = (
f"Hallo {user['name']},\n\n"
"du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n"
f"Klicke hier um ein neues Passwort zu setzen:\n"
f"{app_url}/#reset-password?token={token}\n\n"
"Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n"
"Viele Grüße,\nDas Ban Yaro Team"
)
from routes.outreach import _send_smtp
from mailer import email_html
_ename = _html.escape(user['name'])
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen")
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
_send_smtp(data.email, subject, body, "support")
_send_smtp(data.email, subject, plain, "support", html=html)
except Exception:
pass
return {"ok": True}

View file

@ -11,7 +11,7 @@ from typing import Optional
from database import db
from auth import get_current_user, require_premium
from mailer import send_email
from mailer import send_email, email_html
router = APIRouter()
logger = logging.getLogger(__name__)
@ -131,21 +131,21 @@ async def breeder_apply(
)
# Admin benachrichtigen
admin_html = f"""
<h2>Neuer Züchter-Antrag</h2>
<p><b>Von:</b> {user['name']} ({user['email']})</p>
<p><b>Zwingername:</b> {zwingername}</p>
<p><b>Rasse:</b> {rasse_text}</p>
<p><b>Verein:</b> {verein}</p>
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
<p><b>Stadt:</b> {stadt}</p>
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
"""
admin_body = f"""
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
</table>"""
try:
await send_email(
ADMIN_EMAIL,
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
admin_html,
email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
)
except Exception as e:
@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
)
# Bestätigungs-Mail
html = f"""
<h2>Willkommen als Züchter bei Banyaro!</h2>
<p>Hallo {user['name']},</p>
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
<p><a href="{APP_URL}">Zur App</a></p>
"""
approve_body = f"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
Ab sofort hast du Zugang zu allen Züchter-Features.
</p>"""
try:
await send_email(
user["email"],
"Willkommen als Züchter bei Banyaro!",
html,
"Willkommen als Züchter bei Ban Yaro!",
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
)
except Exception as e:
@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
)
# Ablehnungs-Mail
html = f"""
<h2>Dein Züchter-Antrag bei Banyaro</h2>
<p>Hallo {user['name']},</p>
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
<p><b>Grund:</b> {body.grund}</p>
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
"""
import html as _h
reject_body = f"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
</p>
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
<b>Grund:</b> {_h.escape(body.grund)}
</div>
<p style="margin:0;color:#666;font-size:14px">
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
</p>"""
try:
await send_email(
user["email"],
"Dein Züchter-Antrag bei Banyaro",
html,
"Dein Züchter-Antrag bei Ban Yaro",
email_html(reject_body),
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
)
except Exception as e:

View file

@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)):
d = dict(r)
d["is_guest"] = True
result.append(d)
# HdM-Siege pro Hund anhängen
if result:
dog_ids = [d["id"] for d in result]
with db() as conn:
wins_rows = conn.execute(
f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC",
dog_ids,
).fetchall()
wins_map: dict[int, list[str]] = {}
for w in wins_rows:
wins_map.setdefault(w["dog_id"], []).append(w["monat"])
for d in result:
d["hdm_wins"] = wins_map.get(d["id"], [])
return result
@ -300,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user
values = list(fields.values()) + [dog_id, user["id"]]
with db() as conn:
conn.execute(
updated = conn.execute(
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
)
).rowcount
if not updated:
raise HTTPException(404, "Hund nicht gefunden.")
dog = conn.execute(
"SELECT * FROM dogs WHERE id=?", (dog_id,)
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
return dict(dog)
@ -398,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)):
os.remove(path)
with db() as conn:
conn.execute(
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?",
(dog_id,)
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?",
(dog_id, user["id"])
)

396
backend/routes/expenses.py Normal file
View file

@ -0,0 +1,396 @@
"""BAN YARO — Ausgaben-Tracker Routes"""
import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
datum: str
notiz: Optional[str] = None
class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
datum: Optional[str] = None
notiz: Optional[str] = None
class RecurringCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
startdatum: str # ISO date
notiz: Optional[str] = None
class RecurringUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
haeufigkeit: Optional[str] = None
startdatum: Optional[str] = None
notiz: Optional[str] = None
aktiv: Optional[bool] = None
HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
"""Berechnet das nächste Fälligkeitsdatum nach `after`."""
d = date.fromisoformat(startdatum)
if d > after:
return d
if haeufigkeit == "monatlich":
delta = relativedelta(months=1)
elif haeufigkeit == "quartalsweise":
delta = relativedelta(months=3)
else:
delta = relativedelta(years=1)
while d <= after:
d += delta
return d
def _serialize(row) -> dict:
return dict(row)
# ------------------------------------------------------------------
# GET /api/expenses/summary — Monats- und Jahressummen
# WICHTIG: Diese Route muss VOR /{id} stehen!
# ------------------------------------------------------------------
@router.get("/summary")
async def get_summary(
dog_id: Optional[int] = Query(default=None),
user=Depends(get_current_user),
):
today = date.today()
monat_prefix = today.strftime("%Y-%m")
jahr_prefix = today.strftime("%Y")
extra_cond = ""
extra_params: list = []
if dog_id is not None:
extra_cond = " AND dog_id=?"
extra_params = [dog_id]
with db() as conn:
# Monats-Summen pro Kategorie
rows_monat = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{monat_prefix}%"] + extra_params,
).fetchall()
# Jahres-Summen pro Kategorie
rows_jahr = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{jahr_prefix}%"] + extra_params,
).fetchall()
monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
gesamt_monat = round(sum(monat.values()), 2)
gesamt_jahr = round(sum(jahr.values()), 2)
return {
"monat": monat,
"jahr": jahr,
"gesamt_monat": gesamt_monat,
"gesamt_jahr": gesamt_jahr,
}
# ------------------------------------------------------------------
# GET /api/expenses — Liste mit optionalen Filtern
# ------------------------------------------------------------------
@router.get("")
async def list_expenses(
dog_id: Optional[int] = Query(default=None),
von: Optional[str] = Query(default=None),
bis: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
user=Depends(get_current_user),
):
conditions = ["e.user_id=?"]
params: list = [user["id"]]
if dog_id is not None:
conditions.append("e.dog_id=?")
params.append(dog_id)
if von:
conditions.append("e.datum >= ?")
params.append(von)
if bis:
conditions.append("e.datum <= ?")
params.append(bis)
where = " AND ".join(conditions)
params += [limit, offset]
with db() as conn:
rows = conn.execute(
f"""SELECT e.*, d.name AS dog_name
FROM expenses e
LEFT JOIN dogs d ON d.id = e.dog_id
WHERE {where}
ORDER BY e.datum DESC, e.id DESC
LIMIT ? OFFSET ?""",
params,
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/expenses — neuer Eintrag
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
with db() as conn:
# dog_id prüfen — muss dem User gehören
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
)
row = conn.execute(
"SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# PATCH /api/expenses/{id} — bearbeiten
# ------------------------------------------------------------------
@router.patch("/{expense_id}")
async def update_expense(
expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
):
with db() as conn:
row = conn.execute(
"SELECT * FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
updates["betrag"] = data.betrag
if data.datum is not None:
updates["datum"] = data.datum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
updates["dog_id"] = data.dog_id
if not updates:
return _serialize(row)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [expense_id]
conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# DELETE /api/expenses/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{expense_id}", status_code=204)
async def delete_expense(expense_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None
# ------------------------------------------------------------------
# Wiederkehrende Ausgaben
# ------------------------------------------------------------------
@router.get("/recurring")
async def list_recurring(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT r.*, d.name AS dog_name
FROM recurring_expenses r
LEFT JOIN dogs d ON d.id = r.dog_id
WHERE r.user_id=? ORDER BY r.startdatum DESC""",
(user["id"],),
).fetchall()
return [dict(r) for r in rows]
@router.post("/recurring", status_code=201)
async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.haeufigkeit not in HAEUFIGKEITEN:
raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
today = date.today()
naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
with db() as conn:
if data.dog_id:
if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"])).fetchone():
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO recurring_expenses
(user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
VALUES (?,?,?,?,?,?,?,?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag,
data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
)
row = conn.execute(
"SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return dict(row)
@router.patch("/recurring/{rid}")
async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
updates: dict = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie.")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
updates["betrag"] = data.betrag
if data.haeufigkeit is not None:
if data.haeufigkeit not in HAEUFIGKEITEN:
raise HTTPException(400, "Ungültige Häufigkeit.")
updates["haeufigkeit"] = data.haeufigkeit
if data.startdatum is not None:
updates["startdatum"] = data.startdatum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.aktiv is not None:
updates["aktiv"] = 1 if data.aktiv else 0
if updates:
# naechste_faelligkeit neu berechnen wenn relevante Felder geändert
startdatum = updates.get("startdatum", row["startdatum"])
haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
today = date.today()
updates["naechste_faelligkeit"] = str(
_next_due(startdatum, haeufigkeit, today - timedelta(days=1))
)
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
[*updates.values(), rid])
row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
return dict(row)
@router.delete("/recurring/{rid}", status_code=204)
async def delete_recurring(rid: int, user=Depends(get_current_user)):
with db() as conn:
if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
(rid, user["id"])).fetchone():
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
return None
def process_due_recurring(user_id: int | None = None):
"""Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
today = date.today()
today_str = str(today)
with db() as conn:
where = "aktiv=1 AND naechste_faelligkeit <= ?"
params: list = [today_str]
if user_id:
where += " AND user_id=?"
params.append(user_id)
rows = conn.execute(
f"SELECT * FROM recurring_expenses WHERE {where}", params
).fetchall()
for r in rows:
# Eintrag anlegen
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?,?,?,?,?,?)""",
(r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
r["naechste_faelligkeit"],
f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
)
# Nächste Fälligkeit berechnen
naechste = _next_due(r["startdatum"], r["haeufigkeit"],
date.fromisoformat(r["naechste_faelligkeit"]))
conn.execute(
"UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
(str(naechste), r["id"]),
)
return len(rows) if rows else 0

View file

@ -7,6 +7,8 @@ from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from timeutils import safe_client_time
from ratelimit import is_duplicate_post, record_post
from content_filter import check_forum_content
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
@ -164,6 +166,50 @@ async def list_threads(
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False):
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
# 30-Sekunden-Cooldown zwischen beliebigen Posts
last = conn.execute(
"""SELECT MAX(created_at) AS last FROM (
SELECT created_at FROM forum_threads WHERE user_id=?
UNION ALL
SELECT created_at FROM forum_posts WHERE user_id=?
)""",
(user_id, user_id),
).fetchone()["last"]
if last:
try:
from datetime import datetime as _dt
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
if diff < 30:
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
except (ValueError, TypeError):
pass
# Stunden-Limit
if is_thread:
count = conn.execute(
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
else:
count = conn.execute(
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 20:
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
# Duplikat-Check
if is_duplicate_post(user_id, text):
raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.")
# Content-Filter
check_forum_content(text, user_created_at)
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
t['user_liked'] = False
record_post(user["id"], data.text.strip())
return t
@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
record_post(user["id"], data.text.strip())
# Push-Notification an Thread-Owner (nicht an sich selbst)
if owner_id and owner_id != user['id']:
@ -590,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
async def members_map():
async def members_map(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,

View file

@ -0,0 +1,138 @@
"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)"""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"}
def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
# ------------------------------------------------------------------
# GET /api/health-docs?dog_id=...
# ------------------------------------------------------------------
@router.get("")
async def list_docs(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
rows = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.dog_id=?
ORDER BY hd.created_at DESC""",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/health-docs/upload (multipart/form-data)
# ------------------------------------------------------------------
@router.post("/upload", status_code=201)
async def upload_doc(
dog_id: int = Form(...),
typ: str = Form(...),
titel: str = Form(...),
beschreibung: Optional[str] = Form(None),
datum: Optional[str] = Form(None),
vet_id: Optional[int] = Form(None),
file: UploadFile = File(...),
user=Depends(get_current_user),
):
if typ not in ERLAUBTE_TYPEN:
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}")
ext = os.path.splitext(file.filename or "")[1].lower()
if not ext:
ext = ".jpg"
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
content = await file.read()
if len(content) > MAX_SIZE_BYTES:
raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.")
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
if vet_id:
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone()
if not vet:
vet_id = None
# Datei speichern
dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id))
os.makedirs(dog_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(dog_dir, filename)
with open(filepath, "wb") as f:
f.write(content)
file_url = f"/media/health_docs/{dog_id}/{filename}"
file_type = "pdf" if ext == ".pdf" else ext.lstrip(".")
with db() as conn:
conn.execute(
"""INSERT INTO health_documents
(dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id)
VALUES (?,?,?,?,?,?,?,?,?)""",
(dog_id, user["id"], typ, titel.strip(), beschreibung,
file_url, file_type, datum or None, vet_id)
)
row = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.id = last_insert_rowid()"""
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/health-docs/{id}
# ------------------------------------------------------------------
@router.delete("/{doc_id}", status_code=204)
async def delete_doc(doc_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT * FROM health_documents WHERE id=? AND user_id=?",
(doc_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Dokument nicht gefunden.")
# Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf
file_path = row["file_path"]
if file_path:
# /media/... → MEDIA_DIR/...
rel = file_path.lstrip("/")
if rel.startswith("media/"):
rel = rel[len("media/"):]
abs_path = os.path.join(MEDIA_DIR, rel)
if os.path.isfile(abs_path):
try:
os.remove(abs_path)
except OSError:
pass
conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,))
return None

327
backend/routes/jobs.py Normal file
View file

@ -0,0 +1,327 @@
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
import html as _html
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional, require_admin
from mailer import send_email, email_html
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
JOBS_DIR = os.path.join(MEDIA_DIR, "jobs")
TRIAL_DAYS = 14
MAX_FILES = 3
MAX_FILE_MB = 10
os.makedirs(JOBS_DIR, exist_ok=True)
_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"}
# ------------------------------------------------------------------
# POST /api/jobs/apply
# ------------------------------------------------------------------
async def apply(
name: str = Form(...),
email: str = Form(...),
dog_name: str = Form(""),
dog_rasse: str = Form(""),
social_handle: str = Form(...),
motivation: str = Form(...),
files: list[UploadFile] = File(default=[]),
user = Depends(get_current_user_optional),
):
if len(motivation.strip()) < 80:
raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).")
if len(files) > MAX_FILES:
raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.")
user_id = user["id"] if user else None
# Doppelbewerbung verhindern
if user_id:
with db() as conn:
existing = conn.execute(
"SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')",
(user_id,)
).fetchone()
if existing:
raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.")
with db() as conn:
cur = conn.execute("""
INSERT INTO job_applications
(user_id, name, email, dog_name, dog_rasse, social_handle, motivation)
VALUES (?,?,?,?,?,?,?)
""", (user_id, name.strip(), email.strip(), dog_name.strip(),
dog_rasse.strip(), social_handle.strip(), motivation.strip()))
app_id = cur.lastrowid
# Dokumente speichern
app_dir = os.path.join(JOBS_DIR, str(app_id))
os.makedirs(app_dir, exist_ok=True)
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_EXT:
continue
size = 0
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(app_dir, safe_name)
with open(dest, "wb") as out:
while chunk := await f.read(65536):
size += len(chunk)
if size > MAX_FILE_MB * 1024 * 1024:
out.close()
os.remove(dest)
raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).")
out.write(chunk)
conn.execute("""
INSERT INTO job_application_docs (application_id, filename, file_path)
VALUES (?,?,?)
""", (app_id, f.filename, dest))
# Luna-Probezugang: 14 Tage ab sofort
if user_id:
trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat()
conn.execute(
"UPDATE users SET luna_trial_until=? WHERE id=?",
(trial_until, user_id)
)
# Bestätigungs-Mail an Bewerber
try:
_name = _html.escape(name)
body = f"""
<p style="margin:0 0 16px">Hallo <b>{_name}</b>,</p>
<p style="margin:0 0 16px">
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
</p>
{"<p style='margin:0 0 16px;background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0'><b>🎉 Luna-Probezugang aktiviert!</b><br>Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.</p>" if user_id else ""}
<p style="margin:0;color:#666;font-size:14px">Das Ban Yaro Team</p>"""
await send_email(
email,
"Deine Bewerbung bei Ban Yaro 🐾",
email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"),
f"Hallo {_name}, deine Bewerbung ist eingegangen!",
)
except Exception:
pass
# Admin benachrichtigen
try:
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
_ename = _html.escape(name)
_eemail = _html.escape(email)
_edog_name = _html.escape(dog_name)
_edog_rasse = _html.escape(dog_rasse)
_ehandle = _html.escape(social_handle)
_emotivation = _html.escape(motivation[:300])
admin_body = f"""
<p style="margin:0 0 12px"><b>Neue Job-Bewerbung eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{_ename}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{_eemail}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{_edog_name} ({_edog_rasse})</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{_ehandle}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Anhänge</td><td>{len([f for f in files if f.filename])} Datei(en)</td></tr>
</table>
<p style="margin:12px 0 0;font-size:14px;color:#444">{_emotivation}{"" if len(motivation)>300 else ""}</p>"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neue Bewerbung von {name} ({email})",
)
except Exception:
pass
return {
"ok": True,
"application_id": app_id,
"luna_trial": user_id is not None,
"trial_days": TRIAL_DAYS,
}
# FastAPI braucht expliziten Router-Decorator
router.add_api_route("/apply", apply, methods=["POST"], status_code=201)
# ------------------------------------------------------------------
# GET /api/jobs/my-application
# ------------------------------------------------------------------
@router.get("/my-application")
async def my_application(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, status, admin_note, created_at
FROM job_applications WHERE user_id=?
ORDER BY created_at DESC LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return {"application": None}
return {"application": dict(row)}
# ------------------------------------------------------------------
# GET /api/jobs/luna-trial-status
# ------------------------------------------------------------------
@router.get("/luna-trial-status")
async def luna_trial_status(user=Depends(get_current_user)):
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
if not trial:
return {"active": False}
remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days
return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)}
# ------------------------------------------------------------------
# Admin: Bewerbungen verwalten
# ------------------------------------------------------------------
@router.get("/admin/applications")
async def list_applications(
status: str = "pending",
admin = Depends(require_admin),
):
where = "" if status == "alle" else "WHERE a.status=?"
params = [] if status == "alle" else [status]
with db() as conn:
rows = conn.execute(f"""
SELECT a.*, u.name AS username,
COUNT(d.id) AS doc_count
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN job_application_docs d ON d.application_id = a.id
{where}
GROUP BY a.id
ORDER BY a.created_at DESC
""", params).fetchall()
return [dict(r) for r in rows]
@router.get("/admin/applications/{app_id}")
async def get_application(app_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute(
"""SELECT a.*, u.name AS username, u.email AS user_email
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.id=?""",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
docs = conn.execute(
"SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?",
(app_id,)
).fetchall()
return {**dict(row), "docs": [dict(d) for d in docs]}
@router.patch("/admin/applications/{app_id}")
async def update_application(
app_id: int,
status: Optional[str] = None,
admin_note: Optional[str] = None,
admin = Depends(require_admin),
):
valid = {"pending", "reviewing", "accepted", "rejected"}
if status and status not in valid:
raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}")
with db() as conn:
row = conn.execute(
"SELECT user_id, email, name, status FROM job_applications WHERE id=?",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
updates: dict = {"reviewed_at": datetime.utcnow().isoformat()}
if status:
updates["status"] = status
if admin_note is not None:
updates["admin_note"] = admin_note
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE job_applications SET {set_clause} WHERE id=?",
(*updates.values(), app_id)
)
# Bei Annahme: is_social_media aktivieren + Gründer-Status
if status == "accepted" and row["user_id"]:
conn.execute(
"UPDATE users SET is_social_media=1 WHERE id=?",
(row["user_id"],)
)
founder_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if founder_count < 100:
conn.execute(
"UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0",
(row["user_id"],)
)
# Status-Mail an Bewerber
try:
if status in ("accepted", "rejected", "reviewing"):
_send_status_mail(row["email"], row["name"], status, admin_note or "")
except Exception:
pass
return {"ok": True}
@router.get("/admin/applications/{app_id}/docs/{doc_id}")
async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
with db() as conn:
doc = conn.execute(
"SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?",
(doc_id, app_id)
).fetchone()
if not doc or not os.path.exists(doc["file_path"]):
raise HTTPException(404)
return FileResponse(doc["file_path"], filename=doc["filename"])
def _send_status_mail(email: str, name: str, status: str, note: str):
import asyncio
_ename = _html.escape(name)
texts = {
"reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
f"<p>Hallo <b>{_ename}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
f"<p>Hallo <b>{_ename}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
"rejected": ("Deine Bewerbung bei Ban Yaro",
f"<p>Hallo <b>{_ename}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
}
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {_ename},</p>"))
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{_html.escape(note)}</div>' if note else ""
body = body_start + note_html
async def _send():
await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_send())
else:
loop.run_until_complete(_send())
except Exception:
pass

View file

@ -1,10 +1,11 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
from ratelimit import check as rl_check
from database import db
router = APIRouter()
@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------
class TierarztRequest(BaseModel):
symptom: str
dog_id: Optional[int] = None
dog_name: Optional[str] = None
rasse: Optional[str] = None
@router.post("/tierarzt")
async def ki_tierarzt(req: TierarztRequest, request: Request,
user=Depends(get_current_user)):
"""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:
count = conn.execute(
"SELECT COUNT(*) FROM ki_tierarzt_log "
"WHERE user_id=? AND created_at >= datetime('now','-1 day')",
(user["id"],)
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
dog_name = req.dog_name or "unbekannt"
rasse = req.rasse or "unbekannt"
system = (
"Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
"Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
"kein Ersatz für eine echte tierärztliche Untersuchung. "
"Antworte immer auf Deutsch, klar und verständlich. "
"Stelle keine medizinischen Diagnosen. "
"Empfehle im Zweifel immer den Gang zum Tierarzt."
)
prompt = f"""Hund: {dog_name}, Rasse: {rasse}
Symptom: {req.symptom.strip()}
Gib eine strukturierte, verständliche Einschätzung:
1. Mögliche Ursachen (2-3 wahrscheinlichste)
2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
try:
antwort = await ki_module.complete(
prompt=prompt,
system=system,
max_tokens=600,
requires_premium=False,
user_id=user["id"],
)
# Erfolg: Rate-Limit-Eintrag speichern
with db() as conn:
conn.execute(
"INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
(user["id"], req.dog_id)
)
return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
except ki_module.KIUnavailableError as e:
raise HTTPException(503, str(e))
except HTTPException:
raise
except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# Rate-Limit-Helfer für Rassen-Erkennung
# ------------------------------------------------------------------
_RASSE_DAILY_LIMIT = 10
def _check_rasse_limit(user_id: int) -> int:
"""Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
with db() as conn:
used = conn.execute(
"""SELECT COUNT(*) FROM ki_rasse_log
WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
(user_id,)
).fetchone()[0]
remaining = _RASSE_DAILY_LIMIT - used
if remaining <= 0:
raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
return remaining
def _log_rasse_request(user_id: int):
with db() as conn:
conn.execute(
"INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
)
# ------------------------------------------------------------------
# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
# ------------------------------------------------------------------
@router.post("/rasse-erkennung")
async def ki_rasse_erkennung(
request: Request,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
"""Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
import base64
import json
import re
import anthropic
# Dateigröße prüfen
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
# MIME-Typ prüfen
ct = (file.content_type or "").lower()
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
# MIME-Typ auf erlaubte Werte beschränken
allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
mime_type = ct if ct in allowed_mimes else "image/jpeg"
# Rate-Limit prüfen
remaining_before = _check_rasse_limit(user["id"])
# Anthropic-Client holen (nutzt cached Instanz aus ki.py)
if not ki_module.ANTHROPIC_KEY:
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
api_key = ki_module.ANTHROPIC_KEY
base64_data = base64.standard_b64encode(content).decode("utf-8")
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
Antworte NUR im folgenden JSON-Format (kein anderer Text):
{
"rassen": [
{"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
{"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
],
"ist_hund": true,
"hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
}
Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
try:
def _sync_call():
client = anthropic.Anthropic(api_key=api_key)
return client.messages.create(
model="claude-opus-4-7",
max_tokens=500,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
}
},
{
"type": "text",
"text": prompt_text,
}
]
}]
)
import asyncio
response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
raw = response.content[0].text.strip()
except anthropic.APIError as e:
raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
except Exception as e:
raise HTTPException(500, "Fehler bei der Bildanalyse.")
# JSON parsen — Claude kann manchmal ```json ... ``` wrappen
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
# Usage loggen (erst nach erfolgreicher KI-Antwort)
_log_rasse_request(user["id"])
remaining_after = remaining_before - 1
# Wiki-Slugs für erkannte Rassen nachschlagen
rassen = parsed.get("rassen", [])
if rassen:
with db() as conn:
for r in rassen:
name = r.get("name", "")
# Exakter Name-Match (case-insensitive)
row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
).fetchone()
r["wiki_slug"] = row["slug"] if row else None
return {
"rassen": rassen,
"ist_hund": parsed.get("ist_hund", False),
"hinweis": parsed.get("hinweis") or None,
"verbleibende_anfragen": remaining_after,
}

View file

@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/welfare-confirm")
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
from mailer import send_email
from mailer import send_email, email_html
import os, logging as _log
_logger = _log.getLogger(__name__)
@ -265,19 +265,21 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
html = f"""
<h2>Tierschutz-Hinweis bestätigt</h2>
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
<p>Vater: {eltern['vater_name'] or ''} &nbsp;·&nbsp; Mutter: {eltern['mutter_name'] or ''}</p>
<p>Wurf-ID: {litter_id}</p>
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
"""
import html as _html
welfare_body = f"""
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{_html.escape(zuechter)}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{_html.escape(eltern['vater_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{_html.escape(eltern['mutter_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
</table>"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
html,
email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
)
except Exception as e:

View file

@ -1,4 +1,5 @@
"""BAN YARO — Moderations-Panel Backend"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
@ -69,17 +70,19 @@ async def mod_stats(user=Depends(require_moderator)):
async def mod_reports(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
u.name AS melder_name,
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved,
r.created_at, r.resolved_at,
u.name AS melder_name,
m.name AS resolved_by_name,
CASE r.target_type
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
END AS content_preview
FROM forum_reports r
LEFT JOIN users u ON u.id=r.user_id
WHERE r.resolved=0
ORDER BY r.created_at DESC
LIMIT 100
LEFT JOIN users m ON m.id=r.resolved_by
ORDER BY r.resolved ASC, r.created_at DESC
LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@ -97,8 +100,12 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)):
raise HTTPException(404, "Meldung nicht gefunden.")
new_state = 0 if r["resolved"] else 1
conn.execute(
"UPDATE forum_reports SET resolved=? WHERE id=?",
(new_state, rid)
"""UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=?
WHERE id=?""",
(new_state,
user["id"] if new_state else None,
datetime.utcnow().isoformat() if new_state else None,
rid)
)
return {"ok": True}
@ -189,17 +196,19 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)):
async def mod_fotos(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT s.id, s.foto_url, s.created_at,
SELECT s.id, s.foto_url, s.status, s.created_at,
s.reviewed_at, s.reject_reason,
COALESCE(s.rights_confirmed, 0) AS rights_confirmed,
u.name AS user_name,
r.name AS rasse_name, r.slug AS rasse_slug,
u.name AS user_name,
m.name AS reviewed_by_name,
r.name AS rasse_name, r.slug AS rasse_slug,
r.foto_url AS aktuell_foto
FROM wiki_foto_submissions s
LEFT JOIN users u ON u.id = s.user_id
LEFT JOIN users m ON m.id = s.reviewed_by
LEFT JOIN wiki_rassen r ON r.id = s.rasse_id
WHERE s.status = 'pending'
ORDER BY s.created_at ASC
LIMIT 50
ORDER BY s.status ASC, s.created_at ASC
LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@ -228,11 +237,13 @@ async def mod_poi_edits(user=Depends(require_moderator)):
SELECT e.id, e.osm_id, e.poi_name, e.field,
e.old_value, e.new_value, e.status,
e.created_at, e.resolved_at,
u.name AS einreicher_name
u.name AS einreicher_name,
m.name AS mod_name
FROM osm_poi_edits e
JOIN users u ON u.id = e.user_id
LEFT JOIN users m ON m.id = e.mod_id
ORDER BY e.status ASC, e.created_at DESC
LIMIT 100
LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@ -257,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict,
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
if action == "approve":
_ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"}
if edit["field"] not in _ALLOWED_POI_FIELDS:
raise HTTPException(400, f"Ungültiges Feld: {edit['field']}")
conn.execute(
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
(edit["new_value"], edit["osm_id"])

View file

@ -1,140 +1,380 @@
"""BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from database import db
from auth import get_current_user, get_current_user_optional
from auth import get_current_user, get_current_user_optional, require_admin
router = APIRouter()
# ------------------------------------------------------------------
# Hardcoded Film-Daten
# Seed-Daten — werden beim ersten Start in die DB geschrieben
# ------------------------------------------------------------------
FILME = [
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2},
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0},
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5},
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8},
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3},
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8},
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1},
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0},
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5},
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6},
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3},
_SEED_FILME = [
# ── Originalbestand ──────────────────────────────────────────────
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0},
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4},
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1},
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1},
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2},
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9},
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5},
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3},
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7},
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4},
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3},
# ── Animation / Kinder ──────────────────────────────────────────
{"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"},
{"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"},
{"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
{"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "", "imdb_rating": 6.8, "streaming": "Disney+"},
{"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"},
{"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
{"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7},
{"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
# ── Klassiker vor 1980 ──────────────────────────────────────────
{"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9},
{"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1},
{"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0},
{"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "", "imdb_rating": 7.2, "streaming": "Disney+"},
{"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5},
{"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9},
{"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9},
{"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"},
# ── Wahre Geschichten ───────────────────────────────────────────
{"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"},
{"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"},
{"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
{"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
{"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"},
{"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"},
{"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0},
# ── Arbeitshunde / Polizeihunde ─────────────────────────────────
{"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"},
{"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2},
{"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"},
{"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
{"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1},
# ── Komödien ────────────────────────────────────────────────────
{"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4},
{"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3},
{"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"},
{"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"},
{"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"},
# ── Thriller / Action / Horror ──────────────────────────────────
{"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
{"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8},
{"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"},
{"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"},
# ── Deutsche / österreichische Produktionen ─────────────────────
{"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6},
# ── Neuere Serien ───────────────────────────────────────────────
{"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"},
{"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"},
{"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4},
# ── Klassische Serien ───────────────────────────────────────────
{"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5},
{"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0},
# ── Dokumentationen ─────────────────────────────────────────────
{"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"},
{"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"},
{"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"},
{"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"},
{"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"},
{"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"},
# ── Weitere ─────────────────────────────────────────────────────
{"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
{"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"},
{"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"},
{"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"},
{"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7},
{"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4},
{"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7},
# ── Neue Einträge: Animation ──────────────────────────────────────
{"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8},
{"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4},
{"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"},
{"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"},
{"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"},
{"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"},
{"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"},
{"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"},
{"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0},
{"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"},
{"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"},
# ── Neue Einträge: Familie/Drama ──────────────────────────────────
{"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8},
{"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"},
{"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"},
{"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2},
{"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2},
{"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"},
{"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4},
{"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7},
{"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"},
{"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0},
{"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0},
{"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1},
{"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8},
{"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1},
{"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8},
{"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"},
{"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"},
{"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7},
# ── Neue Einträge: Komödie ────────────────────────────────────────
{"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2},
{"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1},
{"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "", "imdb_rating": 6.3},
{"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "", "imdb_rating": 5.2, "streaming": "Disney+"},
{"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6},
{"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3},
{"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0},
{"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8},
{"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6},
# ── Neue Einträge: Action/Thriller ───────────────────────────────
{"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1},
{"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "", "imdb_rating": 7.2},
{"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4},
# ── Neue Einträge: Japan/International ───────────────────────────
{"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2},
{"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "", "imdb_rating": 8.0},
# ── Neue Einträge: Serien ──────────────────────────────────────────
{"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8},
{"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2},
{"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5},
{"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1},
{"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5},
{"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2},
{"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"},
{"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"},
# ── Neue Einträge: Dokumentationen ────────────────────────────────
{"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8},
{"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4},
{"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6},
{"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"},
{"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7},
{"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2},
{"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8},
]
PROMIS = [
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"},
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""},
_SEED_PROMIS = [
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"},
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""},
{"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"},
{"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"},
{"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (19341947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"},
{"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": ""},
]
def seed_movies():
"""Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE)."""
import logging
logger = logging.getLogger(__name__)
with db() as conn:
for i, f in enumerate(_SEED_FILME):
conn.execute("""
INSERT OR IGNORE INTO movies
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
streaming, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
f["id"], f["titel"], f.get("originaltitel"),
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
f.get("imdb_rating"), f.get("streaming"), i,
))
logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class FilmVoteRequest(BaseModel):
bewertung: int # 15
class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
class MovieCreate(BaseModel):
id: str
titel: str
originaltitel: Optional[str] = None
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 = "🐾"
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
class MovieUpdate(BaseModel):
titel: Optional[str] = None
originaltitel: Optional[str] = None
jahr: Optional[int] = None
genre: Optional[str] = None
typ: Optional[str] = None
hund_rasse: Optional[str] = None
stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = None
bild_emoji: Optional[str] = None
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
# ------------------------------------------------------------------
# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung
# GET /api/movies/filme
# ------------------------------------------------------------------
_SORT_COLS = {
"titel": "m.titel ASC",
"jahr_desc": "m.jahr DESC",
"jahr_asc": "m.jahr ASC",
"imdb": "m.imdb_rating DESC",
"bewertung": "community_avg DESC",
"default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC",
}
@router.get("/filme")
async def get_filme(user=Depends(get_current_user_optional)):
user_ratings = {}
community_avgs = {}
async def get_filme(
sort: str = Query("default"),
typ: str = Query("alle"), # alle | film | serie | doku
user = Depends(get_current_user_optional),
):
order = _SORT_COLS.get(sort, _SORT_COLS["default"])
where = ""
params: list = []
if typ != "alle":
where = "WHERE m.typ = ?"
params.append(typ)
with db() as conn:
if user:
rows = conn.execute(
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
(user["id"],),
).fetchall()
user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
avg_rows = conn.execute(
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
).fetchall()
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
rows = conn.execute(f"""
SELECT m.*,
COALESCE(AVG(v.bewertung), 0) AS community_avg,
COUNT(v.id) AS bewertung_cnt,
uv.bewertung AS user_rating
FROM movies m
LEFT JOIN movie_votes v ON v.film_id = m.id
LEFT JOIN movie_votes uv ON uv.film_id = m.id
AND uv.user_id = ?
{where}
GROUP BY m.id
ORDER BY {order}
""", [user["id"] if user else None] + params).fetchall()
result = []
for film in FILME:
f = dict(film)
f["user_rating"] = user_ratings.get(film["id"])
if film["id"] in community_avgs:
f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
else:
f["bewertung_cnt"] = 0
result.append(f)
for r in rows:
d = dict(r)
d["stirbt_der_hund"] = bool(d["stirbt_der_hund"])
d["bewertung_avg"] = round(d["community_avg"] or 0, 1)
result.append(d)
return result
# ------------------------------------------------------------------
# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert)
# POST /api/movies/filme/{film_id}/vote
# ------------------------------------------------------------------
@router.post("/filme/{film_id}/vote")
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
if not any(f["id"] == film_id for f in FILME):
raise HTTPException(404, "Film nicht gefunden.")
if data.bewertung < 1 or data.bewertung > 5:
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
with db() as conn:
conn.execute(
"""INSERT INTO movie_votes (user_id, film_id, bewertung)
VALUES (?, ?, ?)
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
(user["id"], film_id, data.bewertung),
)
if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone():
raise HTTPException(404, "Film nicht gefunden.")
conn.execute("""
INSERT INTO movie_votes (user_id, film_id, bewertung)
VALUES (?, ?, ?)
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung
""", (user["id"], film_id, data.bewertung))
row = conn.execute(
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
(film_id,),
).fetchone()
return {
"film_id": film_id,
"film_id": film_id,
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
"bewertung_cnt": row["cnt"],
"user_rating": data.bewertung,
"user_rating": data.bewertung,
}
# ------------------------------------------------------------------
# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats
# Admin: CRUD für Filme
# ------------------------------------------------------------------
@router.post("/filme", status_code=201)
async def create_film(data: MovieCreate, admin=Depends(require_admin)):
with db() as conn:
max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0]
try:
conn.execute("""
INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ,
data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung,
data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1))
except Exception:
raise HTTPException(400, "Film-ID bereits vorhanden.")
return {"ok": True}
@router.patch("/filme/{film_id}")
async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)):
updates = {k: v for k, v in data.model_dump(exclude_none=True).items()}
if "stirbt_der_hund" in updates:
updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0
if not updates:
return {"ok": True}
set_clause = ", ".join(f"{k}=?" for k in updates)
with db() as conn:
conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id))
return {"ok": True}
@router.delete("/filme/{film_id}")
async def delete_film(film_id: str, admin=Depends(require_admin)):
with db() as conn:
conn.execute("DELETE FROM movies WHERE id=?", (film_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten)
# ------------------------------------------------------------------
@router.get("/promis")
async def get_promis():
return _SEED_PROMIS
# ------------------------------------------------------------------
# Hund des Monats
# ------------------------------------------------------------------
@router.get("/hund-des-monats")
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute(
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
COUNT(v.id) as stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
JOIN users u ON u.id = d.user_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 10""",
(monat,),
).fetchall()
rows = conn.execute("""
SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
COUNT(v.id) as stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
JOIN users u ON u.id = d.user_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 10
""", (monat,)).fetchall()
user_vote = None
if user:
row = conn.execute(
@ -143,43 +383,55 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
).fetchone()
if row:
user_vote = row["dog_id"]
return {
"monat": monat,
"top": [dict(r) for r in rows],
"user_vote": user_vote,
}
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
@router.get("/hund-des-monats/kandidaten")
async def get_hdm_kandidaten(user=Depends(get_current_user)):
"""Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand."""
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.rasse, d.foto_url,
u.name AS besitzer_name,
COALESCE(v.stimmen, 0) AS stimmen
FROM dogs d
JOIN users u ON u.id = d.user_id
LEFT JOIN (
SELECT dog_id, COUNT(*) AS stimmen
FROM hund_des_monats_votes
WHERE monat = ?
GROUP BY dog_id
) v ON v.dog_id = d.id
WHERE d.is_public = 1
AND d.user_id != ?
ORDER BY
CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END,
stimmen DESC,
d.name ASC
LIMIT 60
""", (monat, user["id"])).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
# ------------------------------------------------------------------
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
# Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist
dog = conn.execute(
"SELECT id, user_id, is_public FROM dogs WHERE id=?",
(data.dog_id,),
).fetchone()
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]:
if dog["user_id"] == user["id"]:
raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
if not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute(
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
VALUES (?, ?, ?)
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
(user["id"], data.dog_id, monat),
)
# Aktuelle Stimmenanzahl für den gewählten Hund
conn.execute("""
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
VALUES (?, ?, ?)
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id
""", (user["id"], data.dog_id, monat))
row = conn.execute(
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
(data.dog_id, monat),
).fetchone()
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}

View file

@ -6,7 +6,7 @@ import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from email.utils import formataddr, formatdate
from datetime import datetime
from typing import List, Optional
@ -84,22 +84,36 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
_log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart:
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative")
msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to
msg["Reply-To"] = acc["from"]
msg.attach(MIMEText(body, "plain", "utf-8"))
if html:
msg.attach(MIMEText(html, "html", "utf-8"))
return msg
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
_LEGAL_FOOTER = (
"\n\n---\n"
"Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
"Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
"Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
"Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
"Datenschutzerklärung: https://banyaro.app/datenschutz\n"
"Widerspruch/Löschung: Einfach auf diese Mail antworten."
)
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
msg = _build_message(to, subject, body, account)
msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
@ -189,6 +203,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)):
# Senden
# ------------------------------------------------------------------
def _plain_to_html_body(text: str) -> str:
import html as h
paragraphs = text.strip().split("\n\n")
parts = []
for p in paragraphs:
escaped = h.escape(p).replace("\n", "<br>")
parts.append(f'<p style="margin:0 0 14px;color:#444">{escaped}</p>')
return "".join(parts)
@router.post("/send")
def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.to:
@ -196,13 +220,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.subject.strip() or not data.body.strip():
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
from mailer import email_html
html = email_html(
_plain_to_html_body(data.body),
footer_text=f"Ban Yaro · banyaro.app · {data.subject}",
)
sent, failed = [], []
for addr in data.to:
addr = addr.strip()
if not addr:
continue
try:
_send_smtp(addr, data.subject, data.body, data.from_account)
_send_smtp(addr, data.subject, data.body, data.from_account, html=html)
sent.append(addr)
with db() as conn:
conn.execute(
@ -224,7 +254,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
def send_support_mail(to: str, subject: str, body: str):
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
_send_smtp(to, subject, body, "support")
from mailer import email_html
html = email_html(_plain_to_html_body(body))
_send_smtp(to, subject, body, "support", html=html)
# ------------------------------------------------------------------
@ -235,7 +267,7 @@ def send_support_mail(to: str, subject: str, body: str):
def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
"""SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at,
ol.from_account, u.name AS sent_by_name
FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by

377
backend/routes/passport.py Normal file
View file

@ -0,0 +1,377 @@
"""BAN YARO — Digitaler Hundepass"""
import io
import secrets
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PassportMeta(BaseModel):
blutgruppe: Optional[str] = None
allergien: Optional[str] = None
besonderheiten: Optional[str] = None
class VaccinationCreate(BaseModel):
krankheit: str
datum: str
naechste: Optional[str] = None
tierarzt: Optional[str] = None
charge_nr: Optional[str] = None
class MedicationCreate(BaseModel):
name: str
dosierung: Optional[str] = None
von: Optional[str] = None
bis: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Eigentümer-Prüfung
# ------------------------------------------------------------------
def _get_own_dog(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
def _load_passport_data(conn, dog_id: int) -> dict:
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
meta = conn.execute(
"SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)
).fetchone()
vaccinations = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
medications = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
return {
"dog": dict(dog),
"meta": dict(meta) if meta else {},
"vaccinations": [dict(v) for v in vaccinations],
"medications": [dict(m) for m in medications],
}
# ------------------------------------------------------------------
# GET /passport/{dog_id} — vollständige Passdaten
# ------------------------------------------------------------------
@router.get("/{dog_id}")
async def get_passport(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
return _load_passport_data(conn, dog_id)
# ------------------------------------------------------------------
# PUT /passport/{dog_id}/meta
# ------------------------------------------------------------------
@router.put("/{dog_id}/meta")
async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(dog_id) DO UPDATE SET
blutgruppe = excluded.blutgruppe,
allergien = excluded.allergien,
besonderheiten = excluded.besonderheiten,
updated_at = excluded.updated_at
""", (dog_id, data.blutgruppe, data.allergien, data.besonderheiten))
return {"ok": True}
# ------------------------------------------------------------------
# POST /passport/{dog_id}/vaccinations
# ------------------------------------------------------------------
@router.post("/{dog_id}/vaccinations")
async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr))
row = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/vaccinations/{vacc_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204)
async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/medications
# ------------------------------------------------------------------
@router.post("/{dog_id}/medications")
async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz))
row = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/medications/{med_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/medications/{med_id}", status_code=204)
async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/share — Share-Token erstellen
# ------------------------------------------------------------------
@router.post("/{dog_id}/share")
async def create_share(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
token = secrets.token_urlsafe(32)
valid_until = (date.today() + timedelta(days=30)).isoformat()
conn.execute("""
INSERT INTO passport_shares (dog_id, token, valid_until)
VALUES (?, ?, ?)
""", (dog_id, token, valid_until))
return {
"token": token,
"valid_until": valid_until,
"url": f"/pass/{token}",
}
# ------------------------------------------------------------------
# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth)
# ------------------------------------------------------------------
@router.get("/share/{token}")
async def get_shared_passport(token: str):
with db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
raise HTTPException(404, "Link nicht gefunden.")
if share["valid_until"] < date.today().isoformat():
raise HTTPException(410, "Dieser Link ist abgelaufen.")
return _load_passport_data(conn, share["dog_id"])
# ------------------------------------------------------------------
# GET /passport/{dog_id}/pdf — PDF generieren
# ------------------------------------------------------------------
@router.get("/{dog_id}/pdf")
async def download_pdf(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
data = _load_passport_data(conn, dog_id)
pdf_bytes = _generate_pdf(data)
dog_name = data["dog"]["name"].replace(" ", "_")
filename = f"Hundepass_{dog_name}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# PDF-Generierung mit fpdf2
# ------------------------------------------------------------------
def _generate_pdf(data: dict) -> bytes:
try:
from fpdf import FPDF
except ImportError:
raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.")
dog = data["dog"]
meta = data["meta"]
vaccs = data["vaccinations"]
meds = data["medications"]
# Datumsformatierung DE
def _fmt_date(d):
if not d:
return ""
try:
return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
# Geschlecht
geschlecht_map = {"m": "Rüde", "w": "Hündin"}
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_page()
# ---- Header ----
pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün
pdf.rect(0, 0, 210, 38, style="F")
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", style="B", size=20)
pdf.set_y(8)
pdf.cell(0, 10, "Ban Yaro", align="C", ln=True)
pdf.set_font("Helvetica", size=11)
pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True)
pdf.set_font("Helvetica", size=8)
pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.set_y(46)
# ---- Hundedaten ----
pdf.set_fill_color(245, 250, 247)
pdf.set_draw_color(200, 200, 200)
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B")
pdf.ln(3)
def _info_row(label, value):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, label + ":", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.cell(0, 6, str(value) if value else "", ln=True)
_info_row("Name", dog["name"])
_info_row("Rasse", dog.get("rasse") or "")
_info_row("Geburtstag", _fmt_date(dog.get("geburtstag")))
_info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), ""))
_info_row("Chip-Nr.", dog.get("chip_nr") or "")
if meta.get("blutgruppe"):
_info_row("Blutgruppe", meta["blutgruppe"])
pdf.ln(5)
# ---- Allergien & Besonderheiten ----
if meta.get("allergien") or meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B")
pdf.ln(3)
if meta.get("allergien"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Allergien:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["allergien"])
if meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Besonderheiten:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["besonderheiten"])
pdf.ln(5)
# ---- Impfungen ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B")
pdf.ln(3)
if vaccs:
# Tabellen-Header
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(50, 6, "Krankheit", border=1, fill=True)
pdf.cell(25, 6, "Datum", border=1, fill=True)
pdf.cell(25, 6, "Nächste fällig", border=1, fill=True)
pdf.cell(55, 6, "Tierarzt", border=1, fill=True)
pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, v in enumerate(vaccs):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill)
pdf.cell(55, 6, (v["tierarzt"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (v["charge_nr"] or "")[:20], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.ln(5)
# ---- Medikamente ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B")
pdf.ln(3)
if meds:
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(55, 6, "Medikament", border=1, fill=True)
pdf.cell(35, 6, "Dosierung", border=1, fill=True)
pdf.cell(25, 6, "Von", border=1, fill=True)
pdf.cell(25, 6, "Bis", border=1, fill=True)
pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, m in enumerate(meds):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (m["dosierung"] or "")[:22], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill)
bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft"
pdf.cell(25, 6, bis, border=1, fill=fill)
pdf.cell(50, 6, (m["notiz"] or "")[:30], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
# ---- Footer ----
pdf.set_y(-15)
pdf.set_font("Helvetica", style="I", size=8)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True)
return bytes(pdf.output())

364
backend/routes/playdate.py Normal file
View file

@ -0,0 +1,364 @@
"""BAN YARO — Playdate-Matching"""
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
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:
return None
try:
from datetime import date
geb = date.fromisoformat(geburtstag[:10])
today = date.today()
monate = (today.year - geb.year) * 12 + (today.month - geb.month)
if today.day < geb.day:
monate -= 1
if monate < 0:
return None
if monate < 24:
return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
jahre = monate // 12
return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
except Exception:
return None
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = None
radius_km: int = 10
beschreibung: Optional[str] = None
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = None
class RequestPatch(BaseModel):
status: str # accepted | declined
# ------------------------------------------------------------------
# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
# ------------------------------------------------------------------
def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
a, b = (min(user_a, user_b), max(user_a, user_b))
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
(a, b)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
(a, b)
)
return cur.lastrowid
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@router.get("/nearby")
async def nearby(lat: float, lon: float, radius: int = 10,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT pl.id AS listing_id,
pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
d.id AS dog_id, d.name AS dog_name, d.rasse,
d.geburtstag, d.foto_url, d.geschlecht
FROM playdate_listings pl
JOIN dogs d ON d.id = pl.dog_id
WHERE pl.aktiv = 1
AND pl.user_id != ?
""", (uid,)).fetchall()
result = []
for r in rows:
dist = _haversine(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],
"dog_id": r["dog_id"],
"dog_name": r["dog_name"],
"rasse": r["rasse"],
"alter": _calc_alter(r["geburtstag"]),
"geschlecht": r["geschlecht"],
"foto_url": r["foto_url"],
"ort_name": r["ort_name"],
"beschreibung": r["beschreibung"],
"entfernung_km": round(dist, 1),
})
result.sort(key=lambda x: x["entfernung_km"])
return result
@router.put("/listing", status_code=200)
async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Sicherstellen dass der Hund dem User gehört
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
existing = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=?",
(data.dog_id,)
).fetchone()
if existing:
conn.execute("""
UPDATE playdate_listings
SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
aktiv=1, updated_at=datetime('now')
WHERE dog_id=?
""", (data.lat, data.lon, data.ort_name, data.radius_km,
data.beschreibung, data.dog_id))
return {"ok": True, "id": existing["id"]}
else:
cur = conn.execute("""
INSERT INTO playdate_listings
(dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
VALUES (?,?,?,?,?,?,?)
""", (data.dog_id, uid, data.lat, data.lon, data.ort_name,
data.radius_km, data.beschreibung))
return {"ok": True, "id": cur.lastrowid}
@router.delete("/listing/{dog_id}", status_code=200)
async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
(dog_id, uid)
).fetchone()
if not row:
raise HTTPException(404, "Inserat nicht gefunden.")
conn.execute(
"UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
(dog_id,)
)
return {"ok": True}
@router.get("/my-listing/{dog_id}")
async def my_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"""SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
FROM playdate_listings WHERE dog_id=? AND user_id=?""",
(dog_id, uid)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/request", status_code=201)
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
own_dog = conn.execute(
"SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
(uid,)
).fetchone()
if not own_dog:
raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
from_dog_id = own_dog["id"]
# Zielhund + Besitzer prüfen
target = conn.execute(
"SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
(data.to_dog_id,)
).fetchone()
if not target:
raise HTTPException(404, "Zielhund nicht gefunden.")
if target["user_id"] == uid:
raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
to_user_id = target["user_id"]
# Doppelte Anfrage verhindern
existing = conn.execute(
"SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
(from_dog_id, data.to_dog_id)
).fetchone()
if existing:
if existing["status"] == "pending":
raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
# Alte abgelehnte Anfrage: löschen und neu anlegen
conn.execute(
"DELETE FROM playdate_requests WHERE id=?",
(existing["id"],)
)
cur = conn.execute("""
INSERT INTO playdate_requests
(from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
VALUES (?,?,?,?,?)
""", (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
request_id = cur.lastrowid
# Chat-Konversation anlegen (ohne Freundschaftspflicht)
conv_id = _ensure_conversation(conn, uid, to_user_id)
# Erste Nachricht mit Kontext senden
intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
if data.nachricht:
intro += f" Meine Nachricht: {data.nachricht}"
conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text)
VALUES (?,?,?)
""", (conv_id, uid, intro))
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(to_user_id, {
"title": "Playdate-Anfrage",
"body": f"{user['name']} möchte ein Treffen vereinbaren!",
"type": "playdate_request",
"tag": f"playdate-{request_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
@router.get("/requests")
async def list_requests(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
incoming = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.from_user_id,
uf.name AS from_user_name,
df.name AS from_dog_name, df.rasse AS from_dog_rasse,
df.foto_url AS from_dog_foto,
df.geburtstag AS from_dog_geburtstag,
dt.name AS to_dog_name
FROM playdate_requests pr
JOIN users uf ON uf.id = pr.from_user_id
JOIN dogs df ON df.id = pr.from_dog_id
JOIN dogs dt ON dt.id = pr.to_dog_id
WHERE pr.to_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.to_user_id,
ut.name AS to_user_name,
dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
dt.foto_url AS to_dog_foto,
df.name AS from_dog_name
FROM playdate_requests pr
JOIN users ut ON ut.id = pr.to_user_id
JOIN dogs dt ON dt.id = pr.to_dog_id
JOIN dogs df ON df.id = pr.from_dog_id
WHERE pr.from_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
def _enrich(rows, direction):
result = []
for r in rows:
d = dict(r)
d["direction"] = direction
if direction == "incoming":
d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
result.append(d)
return result
return {
"incoming": _enrich(incoming, "incoming"),
"outgoing": _enrich(outgoing, "outgoing"),
}
@router.patch("/requests/{req_id}", status_code=200)
async def patch_request(req_id: int, data: RequestPatch,
user=Depends(get_current_user)):
uid = user["id"]
if data.status not in ("accepted", "declined"):
raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
with db() as conn:
req = conn.execute(
"SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
(req_id, uid)
).fetchone()
if not req:
raise HTTPException(404, "Anfrage nicht gefunden.")
if req["status"] != "pending":
raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
conn.execute(
"UPDATE playdate_requests SET status=? WHERE id=?",
(data.status, req_id)
)
conv_id = None
if data.status == "accepted":
conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
try:
from routes.push import send_push_to_user
verb = "angenommen" if data.status == "accepted" else "abgelehnt"
send_push_to_user(req["from_user_id"], {
"title": f"Playdate {verb}!",
"body": f"{user['name']} hat deine Anfrage {verb}.",
"type": "playdate_response",
"tag": f"playdate-{req_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "conversation_id": conv_id}

138
backend/routes/recalls.py Normal file
View file

@ -0,0 +1,138 @@
"""BAN YARO — Rückruf-Alarm (Tierfutter)
RASFF EU Rapid Alert System for Food and Feed
"""
import logging
import httpx
from fastapi import APIRouter
from database import db
router = APIRouter()
logger = logging.getLogger(__name__)
RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters"
RASFF_PARAMS = {
"filters": '{"subject.product_category":["pet food and animal feed"]}',
"pageNumber": 0,
"pageSize": 20,
"sortColumn": "notificationDate",
"sortDirection": "DESC",
}
# ------------------------------------------------------------------
# GET /api/recalls — Letzte 50 Rückrufe
# ------------------------------------------------------------------
@router.get("")
async def list_recalls(q: str = ""):
with db() as conn:
if q:
like = f"%{q}%"
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ?
ORDER BY datum DESC
LIMIT 50
""", (like, like, like, like)).fetchall()
else:
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
ORDER BY datum DESC
LIMIT 50
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# 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."""
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 Exception as e:
logger.error(f"RASFF API-Fehler: {e}")
return []
entries = []
try:
items = data.get("data", {}).get("list", [])
for item in items:
reference = item.get("reference", "")
if not reference:
continue
# Datum
datum_raw = item.get("notificationDate", "")
datum = datum_raw[:10] if datum_raw else ""
# Produkt
subject = item.get("subject") or {}
produkt = subject.get("product", "") or ""
# Gefahr
hazards = subject.get("hazard") or []
gefahr = ""
if hazards:
gefahr = hazards[0].get("hazardDescription", "") or ""
# Herkunft
origin = item.get("origin") or {}
herkunft = origin.get("name", "") or ""
# URL zur RASFF-Seite
url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}"
entries.append({
"external_id": reference,
"titel": produkt or reference,
"produkt": produkt,
"gefahr": gefahr,
"herkunft": herkunft,
"datum": datum,
"quelle": "rasff",
"url": url,
})
except Exception as e:
logger.error(f"RASFF Parsing-Fehler: {e}")
return entries
# ------------------------------------------------------------------
# Interne Hilfsfunktion: Neue Einträge in DB speichern
# ------------------------------------------------------------------
def save_new_recalls(entries: list[dict]) -> list[dict]:
"""Speichert neue Einträge und gibt die Liste der neuen Einträge zurück."""
new_entries = []
for entry in entries:
try:
with db() as conn:
exists = conn.execute(
"SELECT id FROM feed_recalls WHERE external_id=?",
(entry["external_id"],)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO feed_recalls
(external_id, titel, produkt, gefahr, herkunft, datum, quelle, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["external_id"],
entry["titel"],
entry["produkt"],
entry["gefahr"],
entry["herkunft"],
entry["datum"],
entry["quelle"],
entry["url"],
))
new_entries.append(entry)
except Exception as e:
logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}")
return new_entries

114
backend/routes/streak.py Normal file
View file

@ -0,0 +1,114 @@
"""BAN YARO — Trainings-Streak"""
import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
_today = lambda: datetime.date.today().isoformat()
_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
# ------------------------------------------------------------------
# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde)
# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst.
# ------------------------------------------------------------------
@router.get("/streak/leaderboard")
async def get_leaderboard(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute("""
SELECT
u.name AS user_name,
d.name AS dog_name,
d.rasse,
d.foto_url,
ts.current_streak
FROM training_streaks ts
JOIN dogs d ON d.id = ts.dog_id
JOIN users u ON u.id = ts.user_id
WHERE ts.current_streak > 0
AND d.is_public = 1
ORDER BY ts.current_streak DESC
LIMIT 10
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /streak/{dog_id} — aktueller Streak eines Hundes
# ------------------------------------------------------------------
@router.get("/streak/{dog_id}")
async def get_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if not row:
return {"current_streak": 0, "longest_streak": 0, "last_training_date": None}
return dict(row)
# ------------------------------------------------------------------
# POST /streak/{dog_id}/ping — Training heute registrieren
# ------------------------------------------------------------------
@router.post("/streak/{dog_id}/ping")
async def ping_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
today = _today()
yest = _yesterday()
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if row:
cur = row["current_streak"]
longest = row["longest_streak"]
last = row["last_training_date"]
if last == today:
# Bereits heute gepingt — nichts tun
return {"current_streak": cur, "longest_streak": longest, "last_training_date": last}
elif last == yest:
cur += 1
else:
cur = 1
longest = max(longest, cur)
conn.execute(
"UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? "
"WHERE user_id=? AND dog_id=?",
(cur, longest, today, uid, dog_id)
)
else:
cur = 1
longest = 1
conn.execute(
"INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) "
"VALUES (?,?,?,?,?)",
(uid, dog_id, cur, longest, today)
)
return {"current_streak": cur, "longest_streak": longest, "last_training_date": today}

View file

@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
return result
@router.get("/my-favorite")
async def get_my_favorite(user=Depends(get_current_user)):
"""Favoriten-Tierarzt des Users (oder null)."""
with db() as conn:
row = conn.execute(
"""SELECT t.* FROM tieraerzte t
JOIN favorite_vets fv ON fv.vet_id = t.id
WHERE fv.user_id = ?
LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/{vet_id}/favorite")
async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
"""Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
with db() as conn:
vet = conn.execute(
"SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
).fetchone()
if not vet:
raise HTTPException(404, "Tierarzt nicht gefunden.")
existing = conn.execute(
"SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
).fetchone()
if existing:
conn.execute(
"DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
)
return {"is_favorite": False}
else:
conn.execute(
"INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
(user["id"], vet_id)
)
return {"is_favorite": True}
@router.get("")
async def list_tieraerzte(user=Depends(get_current_user)):
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
(user["id"],)
).fetchall()
return [dict(r) for r in rows]
favs = {r["vet_id"] for r in conn.execute(
"SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
).fetchall()}
result = []
for r in rows:
d = dict(r)
d["is_favorite"] = r["id"] in favs
result.append(d)
return result
@router.get("/osm-nearby")

View file

@ -3,8 +3,9 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
from fastapi import APIRouter, Query, HTTPException
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
router = APIRouter()
@ -18,3 +19,15 @@ async def get_weather(
return await weather_module.get_weather_for_location(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wetter nicht verfügbar: {exc}')
@router.get('/forecast')
async def get_weather_forecast(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
user=Depends(get_current_user),
):
try:
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')

View file

@ -317,19 +317,24 @@ async def submit_foto(
if not rights_confirmed:
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
# Dateiformat prüfen
ct = file.content_type or ""
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt.")
_IMAGE_MAGIC = [
b"\xff\xd8\xff", # JPEG
b"\x89PNG\r\n\x1a\n", # PNG
b"RIFF", # WebP (RIFF....WEBP)
b"GIF87a", b"GIF89a", # GIF
]
os.makedirs(SUBMIT_DIR, exist_ok=True)
ts = int(time.time())
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
ts = int(time.time())
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
if not any(content.startswith(magic) for magic in _IMAGE_MAGIC):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).")
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
with open(path, "wb") as f:
f.write(content)
@ -694,11 +699,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT z.*, u.name AS user_name
"""SELECT z.*, u.name AS user_name, m.name AS verified_by_name
FROM wiki_zuchter z
LEFT JOIN users u ON u.id = z.user_id
WHERE z.verified=0
ORDER BY z.created_at ASC""",
LEFT JOIN users m ON m.id = z.verified_by
ORDER BY z.verified ASC, z.created_at ASC
LIMIT 200""",
).fetchall()
return [dict(r) for r in rows]
@ -716,8 +722,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
from datetime import datetime
conn.execute(
"UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,)
"UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?",
(user["id"], datetime.utcnow().isoformat(), zuchter_id)
)
result = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)

View file

@ -100,6 +100,22 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
# Täglich 12:00 — Moderation-Overdue-Check
_scheduler.add_job(
_job_moderation_overdue,
CronTrigger(hour=12, minute=0),
id="moderation_overdue",
replace_existing=True,
misfire_grace_time=1800,
)
# 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
_scheduler.add_job(
_job_quarterly_report,
CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0),
id="quarterly_report",
replace_existing=True,
misfire_grace_time=7200,
)
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
_scheduler.add_job(
_job_ki_health_report,
@ -108,8 +124,40 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 06:30 — Wiederkehrende Ausgaben anlegen
_scheduler.add_job(
_job_recurring_expenses,
CronTrigger(hour=6, minute=30),
id="recurring_expenses",
replace_existing=True,
misfire_grace_time=3600,
)
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
_scheduler.add_job(
_job_hdm_winner,
CronTrigger(day=1, hour=0, minute=5),
id="hdm_winner",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 19:00 Uhr — Streak-Erinnerung
_scheduler.add_job(
_job_streak_reminder,
CronTrigger(hour=19, minute=0),
id="streak_reminder",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF)
_scheduler.add_job(
_job_recall_check,
CronTrigger(hour=8, minute=0),
id="recall_check",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -642,6 +690,115 @@ async def _job_ki_health_report():
# ------------------------------------------------------------------
async def _job_moderation_overdue():
"""Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind."""
import os
from mailer import send_email
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
return
SLA_H = 24
threshold = f"datetime('now', '-{SLA_H} hours')"
overdue = {}
try:
with db() as conn:
n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0]
if n: overdue["Bewerbungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Anträge"] = n
n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Forum-Meldungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Foto-Einreichungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["POI-Korrekturen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Einreichungen (Wiki)"] = n
except Exception as e:
logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}")
return
if not overdue:
logger.info("Moderation-Overdue-Check: Alles im SLA.")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
rows_html = "".join(
f'<tr><td style="padding:6px 12px;font-weight:600;color:#c45000">{label}</td>'
f'<td style="padding:6px 12px;font-size:18px;font-weight:800;color:#c45000">{count}</td></tr>'
for label, count in overdue.items()
)
html = f"""\
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#c45000,#e8733a);padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:800;margin-bottom:2px"> Moderation überfällig</div>
<div style="opacity:.88;font-size:13px">{now_str} · SLA: {SLA_H}h</div>
</div>
<div style="padding:22px 28px">
<p style="color:#444;margin:0 0 16px">Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead><tr style="border-bottom:2px solid #f0e8dc">
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Bereich</th>
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Anzahl</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
<div style="margin-top:20px">
<a href="https://banyaro.app/app/admin" style="display:inline-block;background:#c45000;color:#fff;
text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700;font-size:14px">
Admin-Panel öffnen
</a>
</div>
</div>
<div style="padding:12px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
Ban Yaro · banyaro.app
</div>
</div></body></html>"""
plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \
"\n".join(f"{l}: {c}" for l, c in overdue.items()) + \
"\n\nhttps://banyaro.app/app/admin"
try:
await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain)
logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}")
except Exception as e:
logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}")
def _action_items_html(metrics: dict) -> str:
items = [
("jobs_pending", "Bewerbungen offen"),
("breeder_pending", "Züchter-Anträge"),
("reports_open", "Forum-Meldungen"),
("fotos_pending", "Foto-Einreichungen"),
("poi_edits_pending", "POI-Korrekturen"),
]
open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0]
if not open_items:
body = '<span style="color:#16a34a;font-weight:700">✅ Alles erledigt — nichts offen</span>'
else:
pills = "".join(
f'<span style="display:inline-block;background:#fff3e0;color:#c45000;border:1px solid #e8a857;'
f'border-radius:999px;padding:3px 12px;font-size:12px;font-weight:700;margin:2px 4px 2px 0">'
f'{label} <strong style="background:#c45000;color:#fff;border-radius:999px;'
f'padding:0 7px;margin-left:4px">{count}</strong></span>'
for label, count in open_items
)
body = f'<div style="font-size:13px;font-weight:600;color:#c45000;margin-bottom:8px">⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit</div>{pills}'
link = '<div style="margin-top:10px"><a href="https://banyaro.app/app/admin" style="font-size:12px;color:#C4843A">→ Admin-Panel öffnen</a></div>'
return f'<div style="padding:20px 28px;border-bottom:2px solid #e8a857;background:#fffbf5">' \
f'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Heute zu erledigen</div>' \
f'{body}{link}</div>'
# JOB: Status-Report per Mail (täglich 06:00 Uhr)
# ------------------------------------------------------------------
async def _job_status_report():
@ -669,6 +826,7 @@ async def _job_status_report():
# Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0]
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
@ -677,6 +835,28 @@ async def _job_status_report():
except Exception:
metrics["lost_active"] = 0
# Action Items
try:
metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0]
except Exception:
metrics["jobs_pending"] = 0
try:
metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0]
except Exception:
metrics["breeder_pending"] = 0
try:
metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0]
except Exception:
metrics["reports_open"] = 0
try:
metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0]
except Exception:
metrics["fotos_pending"] = 0
try:
metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0]
except Exception:
metrics["poi_edits_pending"] = 0
# Wiki-Interesse
try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
@ -698,6 +878,9 @@ async def _job_status_report():
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
}
job_rows_html = ""
job_rows_txt = ""
@ -727,6 +910,9 @@ async def _job_status_report():
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div>
<!-- Action Items -->
{_action_items_html(metrics)}
<!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
@ -740,14 +926,14 @@ async def _job_status_report():
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
("Nutzer",metrics["users"]),
("Nutzer gesamt",metrics["users"]),
("Neue Nutzer heute",metrics["users_today"]),
("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]),
("Züchter (pending)",metrics["zuchter_pending"]),
])}
</div>
</div>
@ -761,19 +947,28 @@ async def _job_status_report():
</body>
</html>"""
action_open = [l for k,l in [
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
] if metrics.get(k,0) > 0]
plain = f"""Ban Yaro Status-Report — {now_str}
=== HEUTE ZU ERLEDIGEN ===
{"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
] if metrics.get(k,0) > 0)}
=== Scheduler-Jobs ===
{job_rows_txt}
=== Community ===
Nutzer: {metrics['users']}
Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute)
Hunde: {metrics['dogs']}
Tagebuch-Einträge: {metrics['diary_entries']}
Aktive Giftköder: {metrics['poison_active']}
Vermisste Hunde: {metrics['lost_active']}
'So einen hab ich': {metrics['interesse_hat']}
'Interessiert mich': {metrics['interesse_will']}
Züchter (pending): {metrics['zuchter_pending']}
"""
try:
@ -783,6 +978,133 @@ Züchter (pending): {metrics['zuchter_pending']}
logger.error(f"Status-Report: Mail-Fehler: {e}")
async def _job_quarterly_report():
"""Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL."""
import os, sys
from mailer import send_email, email_html
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.")
_log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y")
quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1
try:
# Report-Script importieren und alle Sections aufrufen
sys.path.insert(0, "/app/scripts")
import importlib, generate_reports as gr
importlib.reload(gr) # sicherstellen dass aktuelle Version
sections = [
("Sicherheit", gr.report_sicherheit),
("Funktionsumfang", gr.report_funktionsumfang),
("Dateien", gr.report_dateien),
("Nutzerübersicht", gr.report_nutzer),
("Partnerliste", gr.report_partner),
("Server & Speicher", gr.report_server),
]
def md_to_html_simple(text: str) -> str:
"""Minimale Markdown→HTML-Konvertierung für E-Mail."""
import html as _h
lines_out = []
in_code = False
in_table = False
for line in text.split("\n"):
if line.startswith("```"):
if in_code:
lines_out.append("</code></pre>")
in_code = False
else:
lines_out.append('<pre style="background:#f5f0ea;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto"><code>')
in_code = True
continue
if in_code:
lines_out.append(_h.escape(line))
continue
if line.startswith("#### "):
lines_out.append(f'<h4 style="margin:12px 0 4px;color:#333">{line[5:]}</h4>')
elif line.startswith("### "):
lines_out.append(f'<h3 style="margin:16px 0 6px;color:#555;font-size:14px;text-transform:uppercase;letter-spacing:.04em">{line[4:]}</h3>')
elif line.startswith("## "):
lines_out.append(f'<h2 style="margin:20px 0 8px;color:#C4843A;font-size:16px;border-bottom:1px solid #f0e8dc;padding-bottom:4px">{line[3:]}</h2>')
elif line.startswith("# "):
pass # Haupttitel kommt vom äußeren Template
elif line.startswith("---"):
pass # Trennlinie überspringen
elif line.startswith("| "):
if not in_table:
lines_out.append('<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0">')
in_table = True
if set(line.replace("|","").replace("-","").replace(" ","")) == set():
continue # Trenn-Zeile
cells = [c.strip() for c in line.split("|")[1:-1]]
row_html = "".join(f'<td style="padding:4px 8px;border-bottom:1px solid #f0e8dc">{_h.escape(c)}</td>' for c in cells)
lines_out.append(f"<tr>{row_html}</tr>")
continue
elif line.startswith("- ") or line.startswith("* "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<li style="margin:2px 0;color:#444">{line[2:]}</li>')
elif line.startswith("> "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<blockquote style="border-left:3px solid #C4843A;margin:8px 0;padding:6px 12px;background:#fdf6ef;color:#555;font-size:13px">{line[2:]}</blockquote>')
elif line.strip() == "":
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append("")
else:
if in_table:
lines_out.append("</table>")
in_table = False
styled = line.replace("**", "<b>", 1).replace("**", "</b>", 1)
lines_out.append(f'<p style="margin:4px 0;color:#444;font-size:14px">{styled}</p>')
if in_table:
lines_out.append("</table>")
if in_code:
lines_out.append("</code></pre>")
return "\n".join(lines_out)
# Body aus allen Sections zusammensetzen
body_parts = []
plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50]
for title, fn in sections:
try:
md = fn()
body_parts.append(
f'<div style="margin-bottom:32px">'
f'<h1 style="font-size:18px;font-weight:800;color:#C4843A;margin:0 0 12px;'
f'border-bottom:2px solid #f0e8dc;padding-bottom:6px">{title}</h1>'
f'{md_to_html_simple(md)}'
f'</div>'
)
plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
except Exception as e:
body_parts.append(f'<p style="color:#dc2626">Fehler in Section {title}: {e}</p>')
plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n")
full_body = "\n".join(body_parts)
full_plain = "\n".join(plain_parts)
subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year}{now_str}"
html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}")
await send_email(admin, subject, html, full_plain)
logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.")
_log_job("quarterly_report", "ok", f"Q{quarter}{admin}")
except Exception as e:
logger.error(f"Quartalsbericht: Fehler: {e}")
_log_job("quarterly_report", "error", str(e))
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
@ -822,3 +1144,147 @@ def _compute_milestone(today: date, bday: date, dog_name: str):
return titel, text
return None
# ------------------------------------------------------------------
# JOB: Hund des Monats — Sieger des Vormonats festlegen
# ------------------------------------------------------------------
async def _job_hdm_winner():
"""Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats."""
today = datetime.now(tz=_TZ)
# Vormonat berechnen
first_this = today.replace(day=1)
last_month = (first_this - timedelta(days=1)).replace(day=1)
monat = last_month.strftime("%Y-%m")
with db() as conn:
# Schon eingetragen?
existing = conn.execute(
"SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,)
).fetchone()
if existing:
logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.")
_log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}")
return
winner = conn.execute("""
SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 1
""", (monat,)).fetchone()
if not winner:
logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.")
_log_job("hdm_winner", "ok", f"keine Stimmen für {monat}")
return
conn.execute(
"INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)",
(winner["dog_id"], monat, winner["stimmen"]),
)
month_label = last_month.strftime("%B %Y")
send_push_to_user(winner["user_id"], {
"type": "hdm_winner",
"title": f"🏆 {winner['name']} ist Hund des Monats!",
"body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!",
"data": {"page": "forum"},
"tag": f"hdm-{monat}",
})
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")
# ------------------------------------------------------------------
# JOB: Streak-Erinnerung (täglich 19:00)
# ------------------------------------------------------------------
async def _job_streak_reminder():
"""
Findet alle User die heute noch nicht trainiert haben (last_training_date < heute)
und deren current_streak > 0. Sendet einen motivierenden Push pro Hund.
"""
today = str(date.today())
logger.info(f"Streak-Reminder Job läuft für {today}")
with db() as conn:
rows = conn.execute("""
SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name
FROM training_streaks ts
JOIN dogs d ON d.id = ts.dog_id
WHERE ts.current_streak > 0
AND (ts.last_training_date IS NULL OR ts.last_training_date < ?)
""", (today,)).fetchall()
sent_total = 0
for r in rows:
n = r["current_streak"]
sent = send_push_to_user(r["user_id"], {
"type": "streak_reminder",
"title": f"🔥 {r['dog_name']} wartet auf sein Training!",
"body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.",
"data": {"page": "uebungen"},
"tag": f"streak-{r['dog_id']}-{today}",
})
sent_total += sent
logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.")
_log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde")
# ------------------------------------------------------------------
# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00)
# ------------------------------------------------------------------
async def _job_recall_check():
"""
Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab.
Neue Einträge werden in DB gespeichert, für jeden wird ein Push
an alle abonnierten User gesendet.
"""
logger.info("Rückruf-Check Job läuft")
try:
from routes.recalls import fetch_rasff_recalls, save_new_recalls
entries = await fetch_rasff_recalls()
if not entries:
logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).")
_log_job("recall_check", "ok", "0 neue Rückrufe (API leer)")
return
new_entries = save_new_recalls(entries)
logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.")
for entry in new_entries:
produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt"
gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen"
ext_id = entry["external_id"]
body = f"{produkt}{gefahr[:80]}"
send_push_to_all({
"title": "⚠️ Tierfutter-Rückruf",
"body": body,
"data": {"page": "recalls"},
"tag": f"recall-{ext_id}",
})
logger.info(f"Rückruf-Push gesendet: {ext_id}{produkt}")
_log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe")
except Exception as e:
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
_log_job("recall_check", "error", str(e))
# ------------------------------------------------------------------
# JOB: Wiederkehrende Ausgaben anlegen
# ------------------------------------------------------------------
async def _job_recurring_expenses():
try:
from routes.expenses import process_due_recurring
count = process_due_recurring()
logger.info(f"Daueraufträge: {count} Einträge angelegt.")
_log_job("recurring_expenses", "ok", f"{count} Einträge")
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))

View file

@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""
BAN YARO Quarterly Report Generator
Aufruf: python3 scripts/generate_reports.py <section>
Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all
"""
import os
import sys
import sqlite3
import subprocess
from datetime import datetime
from pathlib import Path
DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db")
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
APP_DIR = "/app"
NOW = datetime.now()
DATE_STR = NOW.strftime("%d.%m.%Y %H:%M")
ISO_DATE = NOW.strftime("%Y-%m-%d")
# ──────────────────────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ──────────────────────────────────────────────────────────────────────────────
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def q(sql, params=()):
try:
with db() as conn:
return conn.execute(sql, params).fetchall()
except Exception as e:
return []
def q1(sql, params=()):
rows = q(sql, params)
return rows[0] if rows else None
def val(sql, params=(), default=0):
row = q1(sql, params)
if row is None:
return default
return row[0]
def sh(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return r.stdout.strip()
except Exception:
return "(nicht verfügbar)"
def hr():
return "\n---\n"
def h(level, text):
return f"\n{'#' * level} {text}\n"
def table(headers, rows):
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
sep = "| " + " | ".join("-" * w for w in col_widths) + " |"
hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |"
lines = [hdr, sep]
for row in rows:
line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |"
lines.append(line)
return "\n".join(lines)
def bytes_human(b):
for unit in ("B", "KB", "MB", "GB"):
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} TB"
# ──────────────────────────────────────────────────────────────────────────────
# 1 SICHERHEITSBERICHT
# ──────────────────────────────────────────────────────────────────────────────
def report_sicherheit():
# Aktive Bans aus DB
banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1")
unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0")
outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account")
lines = [
f"# Sicherheitsbericht — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
h(2, "Übersicht implementierter Schutzmaßnahmen"),
h(3, "1. Authentifizierung & Passwörter"),
"- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie",
"- **Bcrypt**-Passwort-Hashing mit automatischem Salt",
"- Mindestlänge 8 Zeichen, serverseitig erzwungen",
"- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf",
"",
h(3, "2. Registrierung"),
"- **E-Mail-Verifikation** zwingend vor dem ersten Login",
"- Verifikationslink läuft nach 7 Tagen ab",
"- Rate Limit: 5 Registrierungen / Stunde / IP",
"- Username-Blocklist: >200 reservierte und unangemessene Begriffe",
"- Keine Doppelanmeldung (E-Mail und Username unique)",
"",
h(3, "3. Login-Schutz"),
"- **IP-Rate-Limit**: 10 Versuche / 5 Minuten",
"- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse",
"- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)",
"- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt",
"- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)",
"",
h(3, "4. Forum-Schutz"),
"- E-Mail-Verifikation Pflicht zum Posten",
"- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen",
"- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User",
"- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User",
"- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert",
"- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio",
"- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)",
"- Report-System: User können Beiträge melden",
"",
h(3, "5. HTTP-Security-Headers"),
"| Header | Wert |",
"|--------|------|",
"| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |",
"| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |",
"| `X-Content-Type-Options` | `nosniff` |",
"| `Referrer-Policy` | `strict-origin-when-cross-origin` |",
"| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |",
"",
h(3, "6. Rate Limiting (alle Endpunkte)"),
table(
["Endpunkt", "Limit", "Fenster"],
[
["/auth/register", "5 Req", "60 Min"],
["/auth/login (IP)", "10 Req", "5 Min"],
["/auth/login (Email)", "5 Req", "5 Min"],
["/auth/forgot-password", "3 Req", "60 Min"],
["/auth/resend-verification", "3 Req", "60 Min / Email"],
["/auth/reset-password", "5 Req", "60 Min"],
["KI-Features", "10 Req", "60 Min"],
["Poison-Reports", "3 Req", "60 Min"],
["Wiki-Liste", "60 Req", "60 Sek"],
["Wiki-Detail", "30 Req", "60 Sek"],
]
),
"",
h(3, "7. Honeypot-Fallen"),
"Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:",
"",
"```",
"/api/admin/users /api/v1/users /api/users /api/.env",
"/api/config /api/setup /api/install /api/phpinfo",
"/api/debug /api/actuator /api/swagger /api/graphql",
"/api/wiki/trap",
"```",
"",
h(3, "8. Datei-Upload-Sicherheit"),
"- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM",
"- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`",
"- **Größenbeschränkung**: 20 MB globales Limit (Middleware)",
"- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4",
"- Max. 5 Fotos pro Forum-Thread",
"",
h(3, "9. Admin & Moderation"),
"- Admin-Endpoints per `require_admin` Dependency geschützt",
"- Moderatoren-Rolle mit eingeschränkten Rechten",
"- User-Banning mit Sperrgrund, geprüft bei jedem Request",
"- Outreach-Mailing nur über Admin-Panel, vollständiges Log",
"",
h(2, "Aktuelle Kennzahlen"),
table(
["Metrik", "Wert"],
[
["Gesperrte Accounts", str(banned)],
["Unverifizierte Accounts", str(unverifiziert)],
["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))],
]
),
"",
h(2, "Bekannte Einschränkungen"),
"- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart",
"- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)",
"- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)",
"- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container",
"",
h(2, "Empfehlungen für nächste Überprüfung"),
"- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre",
"- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)",
"- [ ] Login-Logs in DB schreiben (für Audit-Trail)",
"- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren",
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 2 FUNKTIONSUMFANG
# ──────────────────────────────────────────────────────────────────────────────
def report_funktionsumfang():
BEREICHE = [
("Authentifizierung", [
"Registrierung mit E-Mail-Verifikation",
"Login / Logout (JWT + HttpOnly-Cookie)",
"Passwort vergessen / zurücksetzen",
"Verifikations-Mail erneut senden",
"Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)",
"Partner-Codes (Gründer-Slot, eigene Einladungen)",
]),
("Hunde-Profile", [
"Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)",
"Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)",
"Öffentliches Profil mit QR-Code und Teilen-Link",
"Hunde-Ausweis (druckbares HTML-Dokument)",
"Mehrere Hunde pro Account",
]),
("Forum", [
"Thread erstellen mit Kategorien (allgemein, rasse, region, …)",
"Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)",
"Moderatoren: Thread pinnen, sperren, löschen",
"Report-System: Beiträge melden",
"Push-Benachrichtigungen bei neuer Antwort",
"Öffentlich lesbar, Schreiben nur für verifizierte User",
]),
("Tagebuch", [
"Tageseinträge mit Freitext, Fotos, GPS-Koordinaten",
"EXIF-GPS-Extraktion aus Foto-Uploads",
"Kartenansicht aller Tagebuch-Pins",
"Kalenderansicht nach Datum",
"Medienansicht (Galerie aller Fotos)",
"Day-One-kompatibles Format",
]),
("Gesundheit & Training", [
"Gewichtsverlauf mit Diagramm",
"Gesundheits-Erinnerungen (Push, täglich 08:00)",
"104 Übungen (DB-basiert, KI-Trainingspläne)",
"Training-Logging mit Fortschrittsverfolgung",
"KI-Gesundheitsberichte (wöchentlich, cloud/lokal)",
]),
("Karte & POIs", [
"Leaflet-Karte mit Cluster-Markern",
"Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe",
"Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)",
"90-Tage-Cache für Overpass-Abfragen",
"ORS-Routenvorschläge zu Hundeparks",
]),
("Wiki & Rassen", [
"Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)",
"Züchter-Verzeichnis mit Verifikation",
"Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')",
"KI-gestützte Rassen-Anreicherung",
"Wikipedia-basierte Beschreibungen",
]),
("Züchter-Features", [
"Züchter-Antrag mit Dokument-Upload",
"Admin-Prüfung und Freischaltung",
"Züchter-Profil (Zwingername, Rassen, VDH, Stadt)",
"Wurfverwaltung mit Elterntieren, Welpen, Fotos",
"Tierschutz-Check vor Wurf-Anlage",
"Stammbaum-Ansicht",
"Genetik-Tracking (Farbgene, Erbkrankheiten)",
"Kaufvertrags-Generator",
"Jahresbericht-Export",
]),
("Social Features", [
"Freundschaften (anfragen, annehmen, ablehnen)",
"Social-Media-Posts (Luna — KI-Social-Manager)",
"Lober: wöchentlicher KI-Lob-Push (Mo 09:00)",
"Benachrichtigungen (in-app + Push-Notifications)",
]),
("Admin & Moderation", [
"Admin-Dashboard: User-Verwaltung, Ban/Unban",
"Moderation-Queue: gemeldete Beiträge",
"Outreach-Mailing: Templates, Versand, Log",
"Statistiken: User-Wachstum, Aktivität",
"Züchter-Anträge prüfen",
"Partner-Codes verwalten",
"KI-Konfiguration (cloud/lokal, Limits)",
]),
("Infrastruktur", [
"Service Worker (Offline-Stufen 13)",
"Push-Notifications (VAPID)",
"APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …)",
"Brevo E-Mail-API + SMTP-Fallback",
"Analytics: Umami v2 (extern)",
"SEO: robots.txt, sitemap.xml, llms.txt",
"Landing Page + Widget",
]),
]
lines = [
"# Funktionsumfang — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
for bereich, features in BEREICHE:
lines.append(h(2, bereich))
for f in features:
lines.append(f"- {f}")
lines.append("")
# Anzahl Routes aus DB-Query-Kontext (statisch)
lines += [
hr(),
h(2, "Backend-Routers"),
table(
["Router", "Präfix"],
[
["auth", "/api/auth"],
["dogs", "/api/dogs"],
["diary", "/api/diary"],
["health", "/api/health"],
["forum", "/api/forum"],
["wiki", "/api/wiki"],
["map", "/api/map"],
["poison", "/api/poison"],
["lost", "/api/lost"],
["breeder", "/api/breeder"],
["litters", "/api/litters"],
["training", "/api/training"],
["outreach", "/api/outreach"],
["moderation", "/api/moderation"],
["notes", "/api/notes"],
["notifications", "/api/notifications"],
["push", "/api/push"],
["friends", "/api/friends"],
["profile", "/api/profile"],
["social", "/api/social"],
["sitting", "/api/sitting"],
["achievements", "/api/achievements"],
["stats", "/api/stats"],
["walks", "/api/walks"],
["events", "/api/events"],
["alerts", "/api/alerts"],
["ratings", "/api/ratings"],
]
),
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 3 DATEILISTE
# ──────────────────────────────────────────────────────────────────────────────
def report_dateien():
lines = [
"# Dateiliste — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
def scan_dir(title, path, ext):
lines.append(h(2, title))
files = sorted(Path(path).rglob(f"*.{ext}")) if Path(path).exists() else []
rows = []
total = 0
for f in files:
try:
size = f.stat().st_size
total += size
rows.append([str(f.relative_to(path)), bytes_human(size)])
except Exception:
pass
if rows:
lines.append(table(["Datei", "Größe"], rows))
lines.append(f"\n**Gesamt**: {len(rows)} Dateien, {bytes_human(total)}\n")
scan_dir("Backend — Python-Dateien", APP_DIR, "py")
scan_dir("Frontend — JavaScript", f"{APP_DIR}/static/js", "js")
scan_dir("Frontend — CSS", f"{APP_DIR}/static/css", "css")
# HTML-Templates
html_files = list(Path(f"{APP_DIR}/static").glob("*.html")) if Path(f"{APP_DIR}/static").exists() else []
if html_files:
lines.append(h(2, "Frontend — HTML"))
rows = [[f.name, bytes_human(f.stat().st_size)] for f in sorted(html_files)]
lines.append(table(["Datei", "Größe"], rows))
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 4 NUTZERÜBERSICHT
# ──────────────────────────────────────────────────────────────────────────────
def report_nutzer():
lines = [
"# Nutzerübersicht — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Nutzer nach Rolle
lines.append(h(2, "Nutzer nach Rolle"))
total_users = val("SELECT COUNT(*) FROM users")
admins = val("SELECT COUNT(*) FROM users WHERE rolle='admin'")
mods = val("SELECT COUNT(*) FROM users WHERE rolle='moderator' OR is_moderator=1")
breeders = val("SELECT COUNT(*) FROM users WHERE rolle='breeder'")
founders = val("SELECT COUNT(*) FROM users WHERE is_founder=1")
partners = val("SELECT COUNT(*) FROM users WHERE is_partner=1")
banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1")
unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0")
premium = val("SELECT COUNT(*) FROM users WHERE is_premium=1")
lines.append(table(
["Gruppe", "Anzahl"],
[
["Gesamt Nutzer", str(total_users)],
["Admin", str(admins)],
["Moderatoren", str(mods)],
["Züchter", str(breeders)],
["Gründer (aktiv)", str(founders)],
["Partner", str(partners)],
["Premium", str(premium)],
["Gesperrt (banned)", str(banned)],
["E-Mail unverifiziert", str(unverifiziert)],
]
))
# Registrierungen pro Monat (letzte 6 Monate)
lines.append(h(2, "Registrierungen (letzte 6 Monate)"))
reg_rows = q("""
SELECT strftime('%Y-%m', created_at) as monat, COUNT(*) as n
FROM users
WHERE created_at >= date('now', '-6 months')
GROUP BY monat ORDER BY monat
""")
if reg_rows:
lines.append(table(["Monat", "Neue Nutzer"], [(r[0], r[1]) for r in reg_rows]))
else:
lines.append("_Keine Daten_")
lines.append("")
# Hunde
lines.append(h(2, "Hunde"))
dogs = val("SELECT COUNT(*) FROM dogs")
dogs_with_diary = val("SELECT COUNT(DISTINCT dog_id) FROM diary")
lines.append(table(
["Metrik", "Anzahl"],
[
["Hunde gesamt", str(dogs)],
["Hunde mit Tagebuch-Einträgen", str(dogs_with_diary)],
]
))
lines.append("")
# Forum
lines.append(h(2, "Forum"))
threads = val("SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0")
posts = val("SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0")
reports_open = val("SELECT COUNT(*) FROM forum_reports WHERE resolved=0", default=0)
lines.append(table(
["Metrik", "Anzahl"],
[
["Threads", str(threads)],
["Antworten", str(posts)],
["Offene Meldungen", str(reports_open)],
]
))
# Kategorie-Verteilung
kat_rows = q("""
SELECT kategorie, COUNT(*) as n
FROM forum_threads WHERE is_deleted=0
GROUP BY kategorie ORDER BY n DESC
""")
if kat_rows:
lines.append("\n**Threads nach Kategorie:**\n")
lines.append(table(["Kategorie", "Threads"], [(r[0], r[1]) for r in kat_rows]))
lines.append("")
# Tagebuch
lines.append(h(2, "Tagebuch"))
diary_total = val("SELECT COUNT(*) FROM diary")
diary_mit_foto = val("SELECT COUNT(*) FROM diary WHERE foto_url IS NOT NULL AND foto_url != ''")
diary_mit_gps = val("SELECT COUNT(*) FROM diary WHERE lat IS NOT NULL")
lines.append(table(
["Metrik", "Anzahl"],
[
["Einträge gesamt", str(diary_total)],
["Mit Foto", str(diary_mit_foto)],
["Mit GPS-Koordinaten", str(diary_mit_gps)],
]
))
lines.append("")
# Medien (Dateisystem)
lines.append(h(2, "Medien auf dem Server"))
media_root = Path(MEDIA_DIR)
if media_root.exists():
rows = []
total_size = 0
total_count = 0
for subdir in sorted(media_root.iterdir()):
if subdir.is_dir():
files = list(subdir.rglob("*"))
files = [f for f in files if f.is_file()]
size = sum(f.stat().st_size for f in files if f.is_file())
total_size += size
total_count += len(files)
rows.append([subdir.name, str(len(files)), bytes_human(size)])
rows.append(["**GESAMT**", str(total_count), bytes_human(total_size)])
lines.append(table(["Verzeichnis", "Dateien", "Größe"], rows))
else:
lines.append(f"_Media-Verzeichnis nicht gefunden: {MEDIA_DIR}_")
lines.append("")
# Outreach-Mails
lines.append(h(2, "Gesendete E-Mails"))
mail_rows = q("""
SELECT from_account, COUNT(*) as n,
MIN(sent_at) as erste, MAX(sent_at) as letzte
FROM outreach_log
GROUP BY from_account ORDER BY n DESC
""")
if mail_rows:
lines.append(table(
["Absender", "Anzahl", "Erste Mail", "Letzte Mail"],
[(r[0], r[1], r[2][:10] if r[2] else "", r[3][:10] if r[3] else "") for r in mail_rows]
))
total_mails = sum(r[1] for r in mail_rows)
lines.append(f"\n**Gesamt**: {total_mails} Mails gesendet\n")
else:
lines.append("_Noch keine Mails versendet_\n")
# Analytics-Hinweis
lines += [
h(2, "Besuche (Analytics)"),
"> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern "
"über **Umami** erfasst und sind nicht im Container verfügbar. "
"Bitte Umami-Dashboard direkt aufrufen.",
"",
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 5 PARTNERLISTE
# ──────────────────────────────────────────────────────────────────────────────
def report_partner():
lines = [
"# Partnerliste — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Partner-User
lines.append(h(2, "Partner-Accounts"))
partner_users = q("""
SELECT name, email, created_at, founder_number
FROM users WHERE is_partner=1
ORDER BY created_at
""")
if partner_users:
lines.append(table(
["Name", "E-Mail", "Partner seit", "Gründer-Nr."],
[(r[0], r[1], r[2][:10] if r[2] else "", str(r[3]) if r[3] else "") for r in partner_users]
))
else:
lines.append("_Keine Partner-Accounts_")
lines.append("")
# Partner-Codes
lines.append(h(2, "Partner-Codes"))
codes = q("""
SELECT code, grants_founder, max_uses, uses, created_at
FROM partner_codes ORDER BY created_at
""")
if codes:
lines.append(table(
["Code", "Gründer-Slot", "Max. Nutzungen", "Verwendet", "Erstellt"],
[(
r[0],
"Ja" if r[1] else "Nein",
str(r[2]) if r[2] else "",
str(r[3]),
r[4][:10] if r[4] else ""
) for r in codes]
))
else:
lines.append("_Keine Partner-Codes_")
lines.append("")
# Gründer
lines.append(h(2, "Gründer"))
gruender = q("""
SELECT founder_number, name, email, created_at
FROM users WHERE is_founder=1
ORDER BY founder_number
""")
if gruender:
lines.append(table(
["Nr.", "Name", "E-Mail", "Registriert"],
[(r[0], r[1], r[2], r[3][:10] if r[3] else "") for r in gruender]
))
lines.append(f"\n**{len(gruender)} von 100 Gründer-Plätzen belegt.**\n")
else:
lines.append("_Noch keine Gründer_")
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 6 SERVER & SPEICHER
# ──────────────────────────────────────────────────────────────────────────────
def report_server():
lines = [
"# Server & Speicherbelegung — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Disk Usage
lines.append(h(2, "Festplattenbelegung"))
df_out = sh("df -h /data 2>/dev/null || df -h /")
lines.append(f"```\n{df_out}\n```\n")
# Media-Verzeichnisse
lines.append(h(2, "Media-Verzeichnisse"))
du_media = sh(f"du -sh {MEDIA_DIR}/* 2>/dev/null | sort -rh")
du_total = sh(f"du -sh {MEDIA_DIR} 2>/dev/null")
if du_media:
lines.append(f"```\n{du_media}\n\nGesamt: {du_total}\n```\n")
else:
lines.append("_Keine Media-Daten_\n")
# DB-Größe
lines.append(h(2, "Datenbank"))
db_size = sh(f"du -sh {DB_PATH} 2>/dev/null")
db_rows = {}
try:
with db() as conn:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
for t in tables:
name = t[0]
count = conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0]
db_rows[name] = count
except Exception:
pass
lines.append(f"**DB-Größe:** {db_size}\n")
if db_rows:
rows_sorted = sorted(db_rows.items(), key=lambda x: x[1], reverse=True)
lines.append(table(["Tabelle", "Zeilen"], [(k, f"{v:,}") for k, v in rows_sorted]))
lines.append("")
# App-Code Größe
lines.append(h(2, "App-Code"))
du_app = sh(f"du -sh {APP_DIR} 2>/dev/null")
lines.append(f"**App-Verzeichnis ({APP_DIR}):** {du_app}\n")
# Speicher-Kapazität (Warnung wenn >80 %)
lines.append(h(2, "Kapazitäts-Warnung"))
df_pct = sh("df /data 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%' || df / | awk 'NR==2{print $5}' | tr -d '%'")
try:
pct = int(df_pct.strip())
if pct >= 90:
lines.append(f"> ⚠️ **KRITISCH: {pct} % Festplatte belegt!** Sofortige Maßnahmen nötig.")
elif pct >= 80:
lines.append(f"> ⚠️ **Warnung: {pct} % Festplatte belegt.** Bald aufrüsten.")
elif pct >= 70:
lines.append(f"> {pct} % Festplatte belegt — im Blick behalten.")
else:
lines.append(f"> ✅ {pct} % Festplatte belegt — ausreichend Kapazität.")
except (ValueError, TypeError):
lines.append(f"> Belegung: {df_pct}")
lines.append("")
# Python-Pakete
lines.append(h(2, "Installierte Python-Pakete"))
pip_list = sh("pip list --format=columns 2>/dev/null | head -40")
lines.append(f"```\n{pip_list}\n```\n")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────────────────────────────────────
REPORTS = {
"sicherheit": report_sicherheit,
"funktionsumfang": report_funktionsumfang,
"dateien": report_dateien,
"nutzer": report_nutzer,
"partner": report_partner,
"server": report_server,
}
if __name__ == "__main__":
section = sys.argv[1] if len(sys.argv) > 1 else "all"
if section == "all":
for name, fn in REPORTS.items():
print(f"=== REPORT:{name} ===")
print(fn())
print()
elif section in REPORTS:
print(REPORTS[section]())
else:
print(f"Unbekannte Section: {section}", file=sys.stderr)
print(f"Verfügbar: {', '.join(REPORTS.keys())}", file=sys.stderr)
sys.exit(1)

File diff suppressed because it is too large Load diff

View file

@ -105,9 +105,9 @@
--transition-slow: 320ms ease;
/* Navigation */
--nav-bottom-height: 64px;
--nav-bottom-height: 78px; /* Welten-Zurück-FAB: 54px + 20px bottom + 4px Abstand */
--nav-sidebar-width: 240px;
--header-height: 56px;
--header-height: 0px; /* Header entfernt — Welten-Navigation übernimmt */
/* Safe Areas (iPhone Notch/Home Indicator) */
--safe-top: env(safe-area-inset-top, 0px);

View file

@ -19,6 +19,7 @@
min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
overflow-y: auto;
overflow-x: hidden;
padding-top: var(--safe-top);
padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px);
-webkit-overflow-scrolling: touch;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 691 KiB

Before After
Before After

View file

@ -8,6 +8,11 @@
<meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App">
<link rel="canonical" href="https://banyaro.app/">
<!-- Preconnect: externe Hosts frühzeitig verbinden -->
<link rel="preconnect" href="https://umami.motocamp.de">
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin>
<link rel="dns-prefetch" href="https://tile.openstreetmap.org">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
@ -88,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=545">
<link rel="stylesheet" href="/css/layout.css?v=545">
<link rel="stylesheet" href="/css/components.css?v=545">
<link rel="stylesheet" href="/css/design-system.css?v=651">
<link rel="stylesheet" href="/css/layout.css?v=651">
<link rel="stylesheet" href="/css/components.css?v=651">
</head>
<body>
@ -158,6 +163,9 @@
<div class="sidebar-item" data-page="notes">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
</div>
<div class="sidebar-item" data-page="expenses">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#receipt"></use></svg> Ausgaben
</div>
<span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map">
@ -169,12 +177,24 @@
<div class="sidebar-item" data-page="events">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Events
</div>
<div class="sidebar-item" data-page="jobs">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg> Jobs
</div>
<div class="sidebar-item" data-page="adoption">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg> Adoption
</div>
<div class="sidebar-item" data-page="wetter">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sun"></use></svg> Wetter
</div>
<span class="sidebar-section-label">Soziales</span>
<div class="sidebar-item" data-page="friends">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="playdate">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Playdate
</div>
<div class="sidebar-item" data-page="chat">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
@ -186,6 +206,9 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="recalls">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Rückrufe
</div>
<div class="sidebar-item" data-page="walks">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
</div>
@ -452,6 +475,30 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-jobs">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-expenses">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-recalls">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-adoption">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-wetter">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-playdate">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
@ -480,6 +527,34 @@
</div><!-- #app -->
<!-- DREI WELTEN — JETZT | HUND | WELT -->
<div id="worlds-overlay">
<div id="world-dots">
<span class="wdot" data-w="0"></span>
<span class="wdot active" data-w="1"></span>
<span class="wdot" data-w="2"></span>
</div>
<div id="world-labels">
<span class="wlabel" data-w="0">JETZT</span>
<span class="wlabel" data-w="1">HUND</span>
<span class="wlabel" data-w="2">WELT</span>
</div>
<button id="worlds-settings" aria-label="Einstellungen">
<svg class="ph-icon" style="width:20px;height:20px"><use href="/icons/phosphor.svg#gear"></use></svg>
</button>
<div id="worlds-track">
<div class="world-panel" id="wp-jetzt"><div id="wj-content"></div></div>
<div class="world-panel" id="wp-hund"><div id="wh-content"></div></div>
<div class="world-panel" id="wp-welt"><div id="ww-content"></div></div>
</div>
<button id="worlds-fab" aria-label="Hinzufügen">
<svg class="ph-icon" style="width:22px;height:22px"><use href="/icons/phosphor.svg#paw-print"></use></svg>
</button>
</div>
<div id="worlds-back" aria-label="Zurück zur Welten-Navigation">
<svg class="ph-icon" style="width:22px;height:22px"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</div>
<!-- TOAST CONTAINER (außerhalb der App, immer sichtbar) -->
<div class="toast-container" id="toast-container" role="alert" aria-live="polite"></div>
@ -490,6 +565,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=651"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -6,69 +6,84 @@
const API = (() => {
// ----------------------------------------------------------
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
// ----------------------------------------------------------
const _inflight = new Map();
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
async function _request(method, path, body = null, options = {}) {
async function _doRequest(method, path, body, attempt) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
credentials: 'include',
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
delete config.headers['Content-Type'];
config.body = body;
}
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
if (token) config.headers['Authorization'] = `Bearer ${token}`;
let response;
try {
response = await fetch(`/api${path}`, config);
} catch (err) {
const offlineMsg = 'Kein Internet — du bist offline.';
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
throw new APIError(offlineMsg, 0, 'network');
} catch {
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
if (method === 'GET' && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
const msg = 'Kein Internet — du bist offline.';
if (window.UI?.toast) UI.toast.warning(msg, 4000);
throw new APIError(msg, 0, 'network');
}
// 204 No Content
if (response.status === 204) return null;
let data;
try {
data = await response.json();
} catch {
data = null;
}
try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
const isOffline = response.status === 503 && message.startsWith('Offline');
if (isOffline && window.UI && UI.toast) {
UI.toast.warning('Kein Internet — du bist offline.', 4000);
const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
}
// SW hat die Anfrage in die Offline-Queue eingereiht
if (data?._queued) {
if (typeof UI !== 'undefined' && UI.toast) {
if (typeof UI !== 'undefined' && UI.toast)
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
}
return data;
}
return data;
}
async function _request(method, path, body = null) {
// GET-Deduplication: laufende identische Anfragen zusammenfassen
if (method === 'GET') {
if (_inflight.has(path)) return _inflight.get(path);
const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
_inflight.set(path, promise);
return promise;
}
return _doRequest(method, path, body, 0);
}
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
@ -195,6 +210,17 @@ const API = (() => {
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
myFavorite() { return get('/tieraerzte/my-favorite'); },
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
};
// ----------------------------------------------------------
// GESUNDHEITSDOKUMENTE
// ----------------------------------------------------------
const healthDocs = {
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
upload(formData) { return upload('/health-docs/upload', formData); },
delete(id) { return del(`/health-docs/${id}`); },
};
// ----------------------------------------------------------
@ -415,8 +441,9 @@ const API = (() => {
// WETTER
// ----------------------------------------------------------
const weather = {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
@ -712,7 +739,7 @@ const API = (() => {
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
breeder, litters, breederPhotos, zuchthunde, zuchtKi,

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
const App = (() => {
@ -70,6 +70,12 @@ const App = (() => {
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
};
// ----------------------------------------------------------
@ -85,6 +91,7 @@ const App = (() => {
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
};
// ----------------------------------------------------------
@ -92,6 +99,7 @@ const App = (() => {
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
if (window.Worlds?._visible) window.Worlds.hide();
// Aktive Seite ausblenden
document.querySelector('.page.active')?.classList.remove('active');
@ -564,7 +572,7 @@ const App = (() => {
banner.style.display = 'flex';
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
await API.post('/auth/resend-verification', { email: state.user.email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
}, { once: true });
@ -846,6 +854,9 @@ const App = (() => {
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
if (window.Worlds) window.Worlds.init(state);
}
async function _handleInvite(token) {
@ -919,6 +930,8 @@ const App = (() => {
})();
window.App = App; // Worlds kann App.navigate() aufrufen
// App starten
document.addEventListener('DOMContentLoaded', () => {
App.init();

View file

@ -14,11 +14,12 @@ window.Page_admin = (() => {
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
@ -47,6 +48,9 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Tabs -->
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
@ -72,9 +76,68 @@ window.Page_admin = (() => {
});
});
_renderActionItems();
_renderTab();
}
async function _renderActionItems() {
const el = _container.querySelector('#adm-action-items');
if (!el) return;
let d;
try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
];
const open = items.filter(i => d[i.key] > 0);
const usersToday = d.users_today || 0;
el.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${open.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: open.map(i => `
<button data-action-tab="${i.tab}"
style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
${UI.icon(i.icon)} ${i.label}
<span style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">
${d[i.key]}
</span>
</button>`).join('')
}
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
</span>
</div>`;
el.querySelectorAll('[data-action-tab]').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.actionTab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
}
async function _renderTab() {
const el = _container.querySelector('#adm-content');
if (!el) return;
@ -93,6 +156,7 @@ window.Page_admin = (() => {
case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -1396,6 +1460,43 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
// TAB: MODERATION
// ------------------------------------------------------------------
function _ageLabel(createdAt) {
if (!createdAt) return '';
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
const overdue = h >= 24;
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
margin-left:6px;${overdue
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
${overdue ? '⚠️ ' : ''}${label}
</span>`;
}
function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return `
<details style="margin-bottom:var(--space-4)">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
border-top:1px solid var(--c-border)">
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</summary>
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
${items.map(item => `
<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);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${renderItem(item)}
</div>`).join('')}
</div>
</details>`;
}
async function _renderModeration(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -1410,12 +1511,52 @@ window.Page_admin = (() => {
async function _loadModeration(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const [zuchter, fotos] = await Promise.all([
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
API.get('/moderation/reports').catch(() => []),
API.get('/moderation/poi-edits').catch(() => []),
]);
const zuchterPending = zuchter.filter(z => !z.verified);
const zuchterDone = zuchter.filter(z => z.verified);
const fotosPending = fotos.filter(f => f.status === 'pending');
const fotosDone = fotos.filter(f => f.status !== 'pending');
const reportsPending = reports.filter(r => !r.resolved);
const reportsDone = reports.filter(r => r.resolved);
const poiPending = poiEdits.filter(e => e.status === 'pending');
const poiDone = poiEdits.filter(e => e.status !== 'pending');
let html = '';
const modItems = [
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
].filter(i => i.count > 0);
let html = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${modItems.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: modItems.map(i => `
<span style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
${UI.icon(i.icon)} ${i.label}
<strong style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
</span>`).join('')
}
</div>`;
// --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1423,23 +1564,24 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
</h3>`;
if (!zuchter.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
if (!zuchterPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
} else {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody>
${zuchter.map((z, i) => `
${zuchterPending.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
<td class="adm-td">${_ageLabel(z.created_at)}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
@ -1448,6 +1590,10 @@ window.Page_admin = (() => {
</tr>`).join('')}
</tbody></table></div></div>`;
}
// Züchter-History
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
// --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1455,19 +1601,20 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
</h3>`;
if (!fotos.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
if (!fotosPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
${fotos.map(f => `
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
${fotosPending.map(f => `
<div class="card" style="padding:var(--space-4)">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)">
@ -1480,6 +1627,111 @@ window.Page_admin = (() => {
</div>`;
}
// Fotos-History
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
// --- Forum-Meldungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-4) 0 var(--space-3)">
Forum-Meldungen
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${reportsPending.length}
</span>
</h3>`;
if (!reportsPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
} else {
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
${reportsPending.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 style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
${_ageLabel(r.created_at)}
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
</div>
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
${UI.icon('check')}
</button>
</div>
</div>`).join('')}
</div>`;
}
// Meldungen-History
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
// --- POI-Korrekturen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-2) 0 var(--space-3)">
POI-Korrekturen
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${poiPending.length}
</span>
</h3>`;
if (!poiPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden POI-Korrekturen.</p>`;
} else {
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Ort</th>
<th class="adm-th">Feld</th>
<th class="adm-th">Alt</th>
<th class="adm-th">Neu</th>
<th class="adm-th">Von</th>
<th class="adm-th">Alter</th>
<th class="adm-th"></th>
</tr></thead>
<tbody>
${poiPending.map((e, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
<td class="adm-td"><code style="font-size:var(--text-xs)">${_esc(e.field)}</code></td>
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
<td class="adm-td">${_ageLabel(e.created_at)}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
${UI.icon('check')}
</button>
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" style="color:var(--c-danger)">
${UI.icon('x')}
</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div></div>`;
}
// POI-History
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
<code style="font-size:var(--text-xs)">${_esc(e.field)}</code>:
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span>
${_esc(e.new_value||'—')} ·
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
el.innerHTML = html;
// Züchter freigeben
@ -1518,6 +1770,41 @@ window.Page_admin = (() => {
await _loadModeration(el);
});
});
// Forum-Meldung erledigen
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur freigeben
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
UI.toast.success('Korrektur übernommen.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur ablehnen
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
UI.toast.success('Korrektur abgelehnt.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
@ -2132,8 +2419,10 @@ window.Page_admin = (() => {
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
${log.map((l, i) => `
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
@ -2147,6 +2436,28 @@ window.Page_admin = (() => {
</div>
`;
// Log-Zeile: Mail-Inhalt anzeigen
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
row.addEventListener('click', () => {
const l = log[Number(row.dataset.logIdx)];
if (!l) return;
UI.modal.open({
title: _esc(l.subject),
body: `
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
<strong>An:</strong> ${_esc(l.recipient)} &nbsp;·&nbsp;
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app &nbsp;·&nbsp;
${(l.sent_at||'').slice(0,16).replace('T',' ')}
</div>
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);max-height:60vh;overflow-y:auto;
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
});
});
// Vorlage in Compose laden
function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id);
@ -2375,6 +2686,129 @@ window.Page_admin = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending' ? `${UI.icon('clock')} Neu`
: s==='reviewing' ? `${UI.icon('magnifying-glass')} In Prüfung`
: s==='accepted' ? `${UI.icon('check-circle')} Angenommen`
: s==='rejected' ? `${UI.icon('x')} Abgelehnt`
: 'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}> Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}> Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}> Abgelehnt</option>
</select>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };

View file

@ -0,0 +1,958 @@
/* ============================================================
BAN YARO Adoption (Tierheim-Hunde in der Nähe)
Seiten-Modul: Hunde aus deutschen Tierheimen finden.
============================================================ */
window.Page_adoption = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _lat = null;
let _lon = null;
let _radius = 50;
let _rasseFilter = '';
let _activeTab = 'hunde';
let _data = null; // { animals, shelters, has_petfinder }
let _loading = false;
let _communityData = null; // [] listings from /adoption/community
let _myListings = null; // [] eigene Inserate
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
// Standort automatisch versuchen
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
if (_lat && _lon) {
await _loadData();
}
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<!-- Filter-Leiste -->
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3);align-items:center">
<select id="adp-radius" class="form-control" style="width:auto;min-width:110px">
<option value="10">10 km</option>
<option value="25">25 km</option>
<option value="50" selected>50 km</option>
<option value="100">100 km</option>
</select>
<input id="adp-rasse" class="form-control" type="text"
placeholder="Rasse filtern…"
style="flex:1;min-width:120px;max-width:220px"
value="${_esc(_rasseFilter)}">
<button class="btn btn-secondary" id="adp-btn-locate"
style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort
</button>
</div>
<!-- PLZ-Fallback (anfangs versteckt) -->
<div id="adp-plz-row" style="display:none;margin-bottom:var(--space-3)">
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="adp-plz" class="form-control" type="text"
inputmode="numeric" maxlength="5"
placeholder="PLZ eingeben (z.B. 80331)"
style="max-width:180px">
<button class="btn btn-primary" id="adp-btn-geocode">Suchen</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">
Kein Standort verfügbar PLZ als Ausgangspunkt eingeben.
</p>
</div>
<!-- Tabs -->
<div style="display:flex;gap:var(--space-1);margin-bottom:var(--space-4);
border-bottom:1px solid var(--c-border)">
<button class="adp-tab adp-tab--active" data-tab="hunde"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;font-weight:600;color:var(--c-primary);
border-bottom:2px solid var(--c-primary);font-size:var(--text-sm)">
${UI.icon('paw-print')} Hunde
</button>
<button class="adp-tab" data-tab="tierheime"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;color:var(--c-text-secondary);
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('house-line')} Tierheime
</button>
<button class="adp-tab" data-tab="community"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;color:var(--c-text-secondary);
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('heart')} Weitervermittlung
</button>
</div>
<!-- Inhalt -->
<div id="adp-content">
${UI.skeleton(4)}
</div>
`;
// Events
_container.querySelector('#adp-radius')
?.addEventListener('change', e => {
_radius = parseInt(e.target.value);
if (_lat && _lon) _loadData();
});
_container.querySelector('#adp-rasse')
?.addEventListener('input', e => {
_rasseFilter = e.target.value.trim().toLowerCase();
_renderContent();
});
_container.querySelector('#adp-btn-locate')
?.addEventListener('click', _locateUser);
_container.querySelector('#adp-btn-geocode')
?.addEventListener('click', _geocodePLZ);
_container.querySelector('#adp-plz')
?.addEventListener('keydown', e => {
if (e.key === 'Enter') _geocodePLZ();
});
_container.querySelectorAll('.adp-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
_container.querySelectorAll('.adp-tab').forEach(b => {
const isActive = b.dataset.tab === _activeTab;
b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)';
b.style.fontWeight = isActive ? '600' : 'normal';
b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent';
});
_renderContent();
});
});
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 });
_lat = pos.lat;
_lon = pos.lon;
await _loadData();
} catch {
// Standort nicht verfügbar → PLZ-Eingabe zeigen
document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important');
document.getElementById('adp-plz-row').style.display = 'flex';
_showNoLocation();
}
}
async function _locateUser() {
const btn = _container.querySelector('#adp-btn-locate');
if (btn) btn.disabled = true;
try {
const pos = await API.getLocation({ timeout: 10000 });
_lat = pos.lat;
_lon = pos.lon;
document.getElementById('adp-plz-row').style.display = 'none';
await _loadData();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.');
document.getElementById('adp-plz-row').style.display = 'flex';
} finally {
if (btn) btn.disabled = false;
}
}
async function _geocodePLZ() {
const plz = (_container.querySelector('#adp-plz')?.value || '').trim();
if (!plz) return;
const btn = _container.querySelector('#adp-btn-geocode');
if (btn) btn.disabled = true;
try {
const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`);
if (geo.lat && geo.lon) {
_lat = geo.lat;
_lon = geo.lon;
await _loadData();
} else {
UI.toast.error(`PLZ "${plz}" nicht gefunden.`);
}
} catch {
UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.');
} finally {
if (btn) btn.disabled = false;
}
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadData() {
if (_loading || !_lat || !_lon) return;
_loading = true;
const content = _container.querySelector('#adp-content');
if (content) content.innerHTML = UI.skeleton(4);
try {
_data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`);
_renderContent();
} catch {
if (content) content.innerHTML = UI.emptyState({
icon: 'warning',
title: 'Daten konnten nicht geladen werden',
text: 'Bitte versuche es erneut.',
});
} finally {
_loading = false;
}
}
async function _loadCommunity() {
const content = _container.querySelector('#adp-content');
if (content) content.innerHTML = UI.skeleton(4);
try {
const url = _lat && _lon
? `/adoption/community?lat=${_lat}&lon=${_lon}`
: '/adoption/community';
_communityData = await API.get(url);
if (_appState?.user) {
try {
_myListings = await API.get('/adoption/community/my');
} catch {
_myListings = [];
}
}
_renderCommunity(content);
} catch {
if (content) content.innerHTML = UI.emptyState({
icon: 'warning',
title: 'Weitervermittlungs-Inserate konnten nicht geladen werden',
text: 'Bitte versuche es erneut.',
});
}
}
// ----------------------------------------------------------
// INHALT RENDERN (je nach Tab)
// ----------------------------------------------------------
function _renderContent() {
const content = _container.querySelector('#adp-content');
if (!content) return;
if (_activeTab === 'community') {
_loadCommunity();
return;
}
if (!_data) { _showNoLocation(); return; }
if (_activeTab === 'hunde') _renderHunde(content);
else _renderTierheime(content);
}
function _showNoLocation() {
const content = _container.querySelector('#adp-content');
if (!content) return;
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>
<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.
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de
</a>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: HUNDE
// ------------------------------------------------------------------
function _renderHunde(content) {
let animals = (_data?.animals || []);
// Rasse-Filter
if (_rasseFilter) {
animals = animals.filter(a =>
(a.rasse || '').toLowerCase().includes(_rasseFilter) ||
(a.name || '').toLowerCase().includes(_rasseFilter)
);
}
const hasPetFinder = _data?.has_petfinder;
const infoText = hasPetFinder
? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)`
: '';
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.`}
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen
</a>
<a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary">
${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com
</a>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-4)">
Tipp: Schau auch im Tab Tierheime" nach lokalen Tierheimen direkt.
</p>
`;
return;
}
content.innerHTML = `
${infoText ? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">${infoText}</p>` : ''}
<div class="adp-grid"
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:var(--space-3)">
${animals.map(a => _animalCard(a)).join('')}
</div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Mehr Hunde finden:
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size:var(--text-sm)">
${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde
</a>
</div>
`;
// Klick-Events
content.querySelectorAll('[data-adp-url]').forEach(card => {
card.addEventListener('click', () => {
window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer');
});
});
}
function _animalCard(a) {
const foto = a.foto_url
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : '';
const rasseTxt = a.rasse || '';
const tierheim = a.tierheim || '';
return `
<div data-adp-url="${_esc(a.adoptions_url)}"
style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08);
transition:transform .15s,box-shadow .15s"
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
${foto}
</div>
<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)}
</div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(rasseTxt)}
</div>` : ''}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterTxt)}
</span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${a.geschlecht === 'männlich' ? '♂' : '♀'}
</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)}
</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)}
</div>` : ''}
</div>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: TIERHEIME
// ------------------------------------------------------------------
function _renderTierheime(content) {
const shelters = _data?.shelters || [];
if (!shelters.length) {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-6) var(--space-4)">
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Keine Tierheime im Umkreis von ${_radius} km gefunden.
</p>
<a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Tierheimhelden.de
</a>
</div>
`;
return;
}
content.innerHTML = `
<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)">
${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)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Noch mehr Tierheime:
</p>
<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)">
${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)">
${UI.icon('magnifying-glass')} tierschutz.com
</a>
</div>
</div>
`;
}
function _shelterRow(s) {
return `
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit;
border:1px solid var(--c-border);
transition:background .15s"
onmouseenter="this.style.background='var(--c-surface-3)'"
onmouseleave="this.style.background='var(--c-surface-2)'">
<div style="width:40px;height:40px;border-radius:50%;
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:1.2rem">
🏠
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(s.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_esc(s.plz)} ${_esc(s.stadt)}
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
<span style="font-size:var(--text-xs);font-weight:600;
color:var(--c-primary);background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:2px 8px">
${s.distanz_km} km
</span>
<span style="font-size:10px;color:var(--c-text-muted)">Hunde ansehen ${UI.icon('arrow-right')}</span>
</div>
</a>
`;
}
// ------------------------------------------------------------------
// TAB: WEITERVERMITTLUNG (Community)
// ------------------------------------------------------------------
function _renderCommunity(content) {
if (!content) return;
const listings = _communityData || [];
const isLoggedIn = !!_appState?.user;
const fabHtml = isLoggedIn ? `
<button id="adp-fab-create"
style="position:fixed;bottom:calc(var(--nav-height,64px) + var(--space-4));right:var(--space-4);
z-index:100;width:56px;height:56px;border-radius:50%;
background:var(--c-primary);color:#fff;border:none;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.5rem"
title="Hund zur Vermittlung anbieten"
aria-label="Hund zur Vermittlung anbieten">
${UI.icon('plus')}
</button>
` : '';
if (!listings.length) {
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>
<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.
</p>
${isLoggedIn ? `
<button class="btn btn-primary" id="adp-empty-create">
${UI.icon('plus')} Hund zur Vermittlung anbieten
</button>
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Bitte anmelden, um ein Inserat zu erstellen.
</p>
`}
</div>
${fabHtml}
`;
content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal);
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
return;
}
// Eigene Inserate trennen
const myIds = new Set((_myListings || []).map(l => l.id));
content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung
</p>
<div class="adp-grid"
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--space-3)">
${listings.map(l => _communityCard(l)).join('')}
</div>
${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)">
${_myListings.map(l => _myListingRow(l)).join('')}
</div>
</div>
` : ''}
${fabHtml}
`;
// Interest-Button Events
content.querySelectorAll('[data-adp-interest]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.adpInterest;
const interested = btn.dataset.adpInterested === 'true';
_handleInterest(id, interested, btn);
});
});
// FAB
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
// Meine Inserate: Status-Dropdown + Löschen
content.querySelectorAll('[data-adp-status-change]').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.adpStatusChange;
try {
await API.patch(`/adoption/community/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
_loadCommunity();
} catch {
UI.toast.error('Status konnte nicht aktualisiert werden.');
}
});
});
content.querySelectorAll('[data-adp-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Inserat wirklich löschen?')) return;
try {
await API.del(`/adoption/community/${btn.dataset.adpDelete}`);
UI.toast.success('Inserat gelöscht.');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
});
}
function _communityCard(l) {
const foto = l.foto_url
? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
const isActive = !l.status || l.status === 'active';
const statusLabel = l.status === 'reserved' ? 'Reserviert'
: l.status === 'adopted' ? 'Vermittelt'
: '';
const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo'
: l.alter_kategorie === 'jung' ? 'Jung 6Mo2J'
: l.alter_kategorie === 'adult' ? 'Adult 28J'
: l.alter_kategorie === 'senior' ? 'Senior >8J'
: '';
const genderIcon = l.geschlecht === 'maennlich' ? '♂'
: l.geschlecht === 'weiblich' ? '♀'
: '';
const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : '';
const ort = [l.plz, l.ort].filter(Boolean).join(' ');
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">
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"
${!isActive ? 'disabled' : ''}>
Interesse bekunden
</button>`;
return `
<div style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-bg-card,var(--c-surface-2));
box-shadow:0 1px 4px rgba(0,0,0,0.08);
display:flex;flex-direction:column;position:relative">
<!-- Foto -->
<div style="height:140px;overflow:hidden;background:var(--c-surface-3);position:relative">
${foto}
${!isActive ? `
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.45);
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)}
</span>
</div>
` : ''}
</div>
<!-- Body -->
<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)}
</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)}
</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)}
</span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${genderIcon}
</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)}
</span>` : ''}
</div>
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical">
${_esc(l.beschreibung)}
</div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
</div>` : ''}
<div style="margin-top:auto;padding-top:var(--space-1)">
${interestBtn}
</div>
</div>
</div>
`;
}
function _myListingRow(l) {
const statusOptions = [
{ value: 'active', label: 'Aktiv' },
{ value: 'reserved', label: 'Reserviert' },
{ value: 'adopted', label: 'Vermittelt' },
];
return `
<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 style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
</div>
</div>
<select class="form-control" style="width:auto;font-size:var(--text-xs)"
data-adp-status-change="${_esc(l.id)}">
${statusOptions.map(o => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')}
</select>
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
data-adp-delete="${_esc(l.id)}">
${UI.icon('trash')} Löschen
</button>
</div>
`;
}
// ------------------------------------------------------------------
// INTERESSE BEKUNDEN / ZURÜCKZIEHEN
// ------------------------------------------------------------------
async function _handleInterest(id, isInterested, btn) {
if (!_appState?.user) {
UI.toast.error('Bitte anmelden um Interesse zu bekunden.');
return;
}
if (isInterested) {
// Interesse zurückziehen
try {
btn.disabled = true;
await API.del(`/adoption/community/${id}/interest`);
UI.toast.success('Interesse zurückgezogen.');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Fehler beim Zurückziehen des Interesses.');
btn.disabled = false;
}
return;
}
// Interesse bekunden — Modal mit optionaler Nachricht
const body = `
<form id="adp-interest-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Du kannst optional eine Nachricht an den Anbieter schicken.
</p>
<div class="form-group">
<label class="form-label">Nachricht (optional)</label>
<textarea class="form-control" name="nachricht" rows="3"
placeholder="Stell dich kurz vor und erzähle, warum dieser Hund zu dir passt…"></textarea>
</div>
</form>
`;
const footer = `
<div style="display:flex;gap:var(--space-2);width:100%">
<button type="submit" form="adp-interest-form" class="btn btn-primary flex-1" id="adp-interest-submit">
${UI.icon('heart')} Interesse bekunden
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
UI.modal.open({ title: 'Interesse bekunden', body, footer });
document.getElementById('adp-interest-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('adp-interest-submit');
const fd = new FormData(e.target);
const payload = { nachricht: fd.get('nachricht') || null };
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
await API.post(`/adoption/community/${id}/interest`, payload);
UI.modal.close();
UI.toast.success('Interesse gemeldet!');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Fehler beim Melden des Interesses.');
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; }
}
});
}
// ------------------------------------------------------------------
// INSERAT ERSTELLEN — Modal
// ------------------------------------------------------------------
function _openCreateModal() {
if (!_appState?.user) {
UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.');
return;
}
const body = `
<form id="adp-create-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
<input class="form-control" name="name" required placeholder="z.B. Bello">
</div>
<div class="form-group">
<label class="form-label">Rasse (optional)</label>
<input class="form-control" name="rasse" placeholder="z.B. Labrador Mischling">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div class="form-group">
<label class="form-label">Alter</label>
<select class="form-control" name="alter_kategorie">
<option value="">Unbekannt</option>
<option value="welpe">Welpe &lt;6Mo</option>
<option value="jung">Jung 6Mo2J</option>
<option value="adult">Adult 28J</option>
<option value="senior">Senior &gt;8J</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
<select class="form-control" name="geschlecht">
<option value="">Unbekannt</option>
<option value="maennlich">Männlich</option>
<option value="weiblich">Weiblich</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<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 ? '' : '')}">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
<input class="form-control" name="ort" placeholder="z.B. München">
</div>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-danger)">*</span></label>
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
</div>
<div class="form-group">
<label class="form-label">Hintergrund (optional)</label>
<textarea class="form-control" name="hintergrund" rows="2"
placeholder="Warum suchst du ein neues Zuhause? (Krankheit, Umzug, Allergie…)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Foto (optional)</label>
<input class="form-control" type="file" name="foto" accept="image/*" id="adp-create-foto">
</div>
</form>
`;
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">
${UI.icon('plus')} Inserat erstellen
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer });
document.getElementById('adp-create-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('adp-create-submit');
const fd = new FormData(e.target);
// Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text)
const beschreibung = (fd.get('beschreibung') || '').trim();
if (beschreibung.length < 80) {
UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.');
return;
}
// FormData für multipart aufbauen
const postData = new FormData();
postData.append('name', fd.get('name') || '');
postData.append('rasse', fd.get('rasse') || '');
postData.append('alter_kategorie', fd.get('alter_kategorie') || '');
postData.append('geschlecht', fd.get('geschlecht') || '');
postData.append('plz', fd.get('plz') || '');
postData.append('ort', fd.get('ort') || '');
postData.append('beschreibung', beschreibung);
postData.append('hintergrund', fd.get('hintergrund') || '');
if (_lat) postData.append('lat', _lat);
if (_lon) postData.append('lon', _lon);
const fotoFile = document.getElementById('adp-create-foto')?.files?.[0];
if (fotoFile) postData.append('foto', fotoFile);
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
await API.upload('/adoption/community', postData);
UI.modal.close();
UI.toast.success('Inserat erstellt!');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch (err) {
UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`;
}
}
});
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN
// ----------------------------------------------------------
function _formatAlter(jahre) {
if (jahre < 0.5) return 'Welpe';
if (jahre < 1) return 'Jungtier';
if (jahre < 2) return `${Math.round(jahre)} Jahr`;
if (jahre < 10) return `${Math.round(jahre)} Jahre`;
return 'Senior';
}
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -187,16 +187,14 @@ window.Page_dog_profile = (() => {
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
</button>` : ''}
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis
</button>
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>` : ''}
</div>
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
Hundepass
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>` : ''}
@ -209,7 +207,8 @@ window.Page_dog_profile = (() => {
<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)">
Gib einem Freund temporären Schreibzugang für diesen Hund
Gib einem Freund temporären Schreibzugang für diesen Hund.
Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen.
</div>
</div>
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade</div>
@ -257,14 +256,14 @@ window.Page_dog_profile = (() => {
_showChipEdit(dog);
});
document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => {
_showAusweisModal(dog.id);
});
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
_showShareModal(dog);
});
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
_showPassportModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -745,13 +744,7 @@ window.Page_dog_profile = (() => {
// AUSWEIS
// ----------------------------------------------------------
function _showAusweisModal(dogId) {
UI.modal.open({
title: 'Heimtierausweis',
body: `<iframe src="/ausweis/${dogId}" class="ausweis-frame" title="Heimtierausweis"></iframe>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a href="/ausweis/${dogId}" target="_blank" class="btn btn-ghost">${UI.icon('printer')} Drucken</a>`,
size: 'fullscreen',
});
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
}
// ----------------------------------------------------------
@ -996,7 +989,7 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
@ -1007,6 +1000,16 @@ window.Page_dog_profile = (() => {
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
style="margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Rasse erkennen
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" style="display:none">
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
</div>
</div>
@ -1086,6 +1089,9 @@ window.Page_dog_profile = (() => {
});
}
// Rassen-Erkennung per KI
_bindRasseErkennung();
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
@ -1171,6 +1177,152 @@ window.Page_dog_profile = (() => {
});
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Formular)
// ----------------------------------------------------------
function _bindRasseErkennung() {
const btn = document.getElementById('dp-rasse-erkennen-btn');
const fileInput = document.getElementById('dp-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast.error('Bild zu groß (max. 5 MB).');
return;
}
const origLabel = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origLabel;
_showRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origLabel;
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
}
});
}
function _showRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
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)">
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>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
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>
<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>` : ''}
<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">
Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Diese wählen
</button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${_esc(r.wiki_slug)}">
Im Wiki
</button>` : ''}
</div>
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<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>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('dp-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
btn.addEventListener('click', () => {
const rasse = btn.dataset.rasse;
const rasseInput = document.getElementById('dp-rasse-input');
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
if (rasseInput) {
rasseInput.value = rasse;
rasseInput.dispatchEvent(new Event('input'));
}
UI.modal.close();
UI.toast.success(`Rasse "${rasse}" übernommen.`);
});
});
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
App.navigate('wiki');
setTimeout(() => {
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
Page_wiki._openBreedDetail(btn.dataset.slug);
}
}, 400);
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
@ -1196,6 +1348,444 @@ window.Page_dog_profile = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HUNDEPASS
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis öffnen
</a>
<button class="btn btn-secondary" id="pp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link teilen
</button>
<a class="btn btn-primary" id="pp-pdf-btn"
href="/api/passport/${dog.id}/pdf" target="_blank" download>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
PDF
</a>
</div>`,
size: 'large',
});
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
_createPassportShare(dog);
});
await _loadPassportBody(dog);
}
async function _loadPassportBody(dog) {
const wrap = document.getElementById('pp-body');
if (!wrap) return;
let data;
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>`;
return;
}
const _fmt = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
const meta = data.meta || {};
const vaccs = data.vaccinations || [];
const meds = data.medications || [];
wrap.innerHTML = `
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Gesundheits-Info
</span>
<button class="btn btn-secondary btn-sm" id="pp-meta-edit-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
Bearbeiten
</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${_esc(meta.blutgruppe) || '<span style="color:var(--c-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>
</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>
</div>` : ''}
</div>
<!-- Impfungen -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
Impfungen
</span>
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
</div>
<div id="pp-vacc-list">
${vaccs.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Impfungen eingetragen.<br>Klicke auf + Eintragen" um loszulegen.</p>
</div>`
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
<!-- Medikamente -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
Medikamente
</span>
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
</div>
<div id="pp-med-list">
${meds.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Medikamente eingetragen.<br>Klicke auf + Eintragen" um loszulegen.</p>
</div>`
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
`;
// Meta bearbeiten
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
});
// Impfung hinzufügen
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
_addVaccination(dog, () => _loadPassportBody(dog));
});
// Impfung löschen
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Impfung wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
// Medikament hinzufügen
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
_addMedication(dog, () => _loadPassportBody(dog));
});
// Medikament löschen
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Medikament wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
}
function _editPassportMeta(dog, current, onSave) {
UI.modal.open({
title: 'Gesundheits-Info bearbeiten',
body: `
<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">
</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>
</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>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
</div>`,
});
document.getElementById('pp-meta-save').addEventListener('click', async () => {
const btn = document.getElementById('pp-meta-save');
UI.setLoading(btn, true);
try {
await API.put(`/passport/${dog.id}/meta`, {
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
allergien: document.getElementById('pp-meta-al').value.trim() || null,
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Gesundheits-Info gespeichert.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addVaccination(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Impfung eintragen',
body: `
<div class="form-group">
<label class="form-label">Krankheit *</label>
<input id="pp-vacc-krankheit" class="form-control" type="text"
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
<datalist id="pp-vacc-list">
<option value="Staupe">
<option value="Parvovirose">
<option value="Hepatitis (HCC)">
<option value="Leptospirose">
<option value="Tollwut">
<option value="Kennel-Husten (Bordetella)">
<option value="Borreliose">
<option value="DHPP (Kombi)">
</datalist>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Nächste fällig</label>
<input id="pp-vacc-naechste" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
</div>
<div class="form-group">
<label class="form-label">Charge-Nr.</label>
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
</div>`,
});
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
const datum = document.getElementById('pp-vacc-datum').value;
if (!krankheit || !datum) {
UI.toast.warning('Bitte Krankheit und Datum angeben.');
return;
}
const btn = document.getElementById('pp-vacc-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/vaccinations`, {
krankheit,
datum,
naechste: document.getElementById('pp-vacc-naechste').value || null,
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Impfung eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addMedication(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Medikament eintragen',
body: `
<div class="form-group">
<label class="form-label">Medikament *</label>
<input id="pp-med-name" class="form-control" type="text"
placeholder="z. B. Frontline, Milbemax, Onsior">
</div>
<div class="form-group">
<label class="form-label">Dosierung</label>
<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="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>
<input id="pp-med-bis" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input id="pp-med-notiz" class="form-control" type="text"
placeholder="z. B. nach dem Fressen geben">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
</div>`,
});
document.getElementById('pp-med-save').addEventListener('click', async () => {
const name = document.getElementById('pp-med-name').value.trim();
if (!name) {
UI.toast.warning('Bitte einen Namen angeben.');
return;
}
const btn = document.getElementById('pp-med-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/medications`, {
name,
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
von: document.getElementById('pp-med-von').value || null,
bis: document.getElementById('pp-med-bis').value || null,
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Medikament eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
async function _createPassportShare(dog) {
const btn = document.getElementById('pp-share-btn');
if (btn) UI.setLoading(btn, true);
try {
const res = await API.post(`/passport/${dog.id}/share`, {});
const url = `${location.origin}${res.url}`;
if (btn) UI.setLoading(btn, false);
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
const shareWrap = document.createElement('div');
shareWrap.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
</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)">
<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>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
</p>`;
UI.modal.open({
title: 'Hundepass-Link teilen',
body: shareWrap.innerHTML,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(url).catch(() => {});
UI.toast.success('Link kopiert!');
});
} catch (e) {
if (btn) UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -0,0 +1,828 @@
/* ============================================================
BAN YARO Ausgaben-Tracker
Tabs: Übersicht | Einträge | Statistik
============================================================ */
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
// Cache
let _summary = null;
let _entries = [];
let _statsData = null;
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
{ id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
];
const KATEGORIEN = [
{ id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' },
{ id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' },
{ id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' },
{ id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' },
{ id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' },
{ id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' },
];
function _kat(id) {
return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' };
}
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_summary = null;
_entries = [];
_statsData = null;
_render();
}
async function refresh() {
_summary = null;
_entries = [];
_statsData = null;
await _renderTab();
}
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
</button>
`;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.tab;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
_container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null));
_renderTab();
}
// ----------------------------------------------------------
// TAB ROUTER
// ----------------------------------------------------------
async function _renderTab() {
const el = _container.querySelector('#exp-content');
if (!el) return;
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
try {
switch (_tab) {
case 'uebersicht': await _renderUebersicht(el); break;
case 'eintraege': await _renderEintraege(el); break;
case 'dauerauftraege': await _renderDauerauftraege(el); break;
case 'statistik': await _renderStatistik(el); break;
}
} catch (e) {
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
}
}
// ----------------------------------------------------------
// TAB: ÜBERSICHT
// ----------------------------------------------------------
async function _renderUebersicht(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
const s = _summary;
// Vormonatsvergleich berechnen
const letzteMonat = await _getLetzteMonateData();
const trendHtml = _trendHtml(letzteMonat);
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
return `
<div class="exp-kachel">
<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(betrag)}</div>
<div class="exp-kachel-label">${k.label}</div>
</div>`;
}).join('');
const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : '';
el.innerHTML = `
<div class="exp-hero-card">
<div class="exp-hero-label">Dieser Monat</div>
<div class="exp-hero-betrag">${_fmt(s.gesamt_monat)}</div>
<div class="exp-hero-meta">
${UI.icon('calendar')} Dieses Jahr: <strong>${_fmt(s.gesamt_jahr)}</strong>
${trendHtml}
</div>
</div>
<div class="exp-kachel-grid">${kacheln}</div>
${verlauf}
<div style="height:80px"></div>
`;
}
async function _getLetzteMonateData() {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const monatMap = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7);
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
return Object.entries(monatMap)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 6)
.reverse();
}
function _trendHtml(data) {
// Vergleich: aktueller Monat vs. Vormonat
if (data.length < 2) return '';
const aktuell = data[data.length - 1][1];
const vormonat = data[data.length - 2][1];
if (!vormonat) return '';
const diff = aktuell - vormonat;
const pct = Math.round(Math.abs(diff / vormonat) * 100);
if (pct === 0) return '';
const pfeil = diff > 0
? `<span class="exp-trend exp-trend--up">${UI.icon('arrow-up')} +${pct}% ggü. Vormonat</span>`
: `<span class="exp-trend exp-trend--down">${UI.icon('arrow-down')} ${pct}% ggü. Vormonat</span>`;
return pfeil;
}
function _vergleichHtml(data) {
if (!data.length) return '';
const max = Math.max(...data.map(d => d[1]), 1);
const balken = data.map(([monat, summe]) => {
const pct = Math.round((summe / max) * 100);
const [y, m] = monat.split('-');
const label = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'short' });
return `
<div class="exp-bar-item">
<div class="exp-bar-track">
<div class="exp-bar-fill" style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
<div class="exp-bar-val">${_fmtShort(summe)}</div>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
<div class="exp-bar-chart">${balken}</div>
</div>`;
}
// ----------------------------------------------------------
// TAB: EINTRÄGE
// ----------------------------------------------------------
async function _renderEintraege(el) {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
if (!_entries.length) {
el.innerHTML = UI.emptyState({
icon: UI.icon('receipt'),
title: 'Noch keine Ausgaben',
text: 'Tippe auf + um deine erste Ausgabe einzutragen.',
});
return;
}
// Nach Monat gruppieren
const groups = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7);
if (!groups[m]) groups[m] = [];
groups[m].push(e);
});
const html = Object.entries(groups)
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([monat, items]) => {
const [y, m] = monat.split('-');
const titel = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
const summe = items.reduce((s, e) => s + e.betrag, 0);
const rows = items.map(e => {
const k = _kat(e.kategorie);
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>`
: '';
const notiz = e.notiz
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
: '';
return `
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
${notiz}
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
aria-label="Eintrag löschen">
${UI.icon('trash')}
</button>
</div>
</div>`;
}).join('');
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>
${rows}
</div>`;
}).join('');
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
el.querySelectorAll('.exp-entry').forEach(row => {
row.addEventListener('click', (ev) => {
if (ev.target.closest('.exp-entry-del')) return;
const id = parseInt(row.dataset.id);
const entry = _entries.find(e => e.id === id);
if (entry) _showForm(entry);
});
});
// Löschen-Buttons
el.querySelectorAll('.exp-entry-del').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const id = parseInt(btn.dataset.del);
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
try {
await API.del(`/expenses/${id}`);
UI.toast.success('Ausgabe gelöscht.');
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
});
}
// ----------------------------------------------------------
// TAB: DAUERAUFTRÄGE
// ----------------------------------------------------------
const HAEUFIGKEIT_LABEL = {
monatlich: 'Monatlich',
quartalsweise: 'Quartalsweise',
jaehrlich: 'Jährlich',
};
async function _renderDauerauftraege(el) {
let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
const cards = recurring.map(r => {
const k = _kat(r.kategorie);
const naechste = r.naechste_faelligkeit
? new Date(r.naechste_faelligkeit + 'T00:00:00')
.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>
</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>
</div>`;
}).join('');
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
${UI.icon('plus')} Dauerauftrag
</button>
</div>
${recurring.length
? `<div class="exp-list">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
<div style="height:80px"></div>`;
el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => {
_tab = 'dauerauftraege'; _renderTab();
}));
el.querySelectorAll('.exp-recurring-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const rid = parseInt(btn.dataset.rid);
const aktiv = btn.dataset.aktiv === '1';
await UI.asyncButton(btn, async () => {
await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv });
_renderTab();
});
});
});
el.querySelectorAll('.exp-recurring-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Dauerauftrag löschen?')) return;
await UI.asyncButton(btn, async () => {
await API.del(`/expenses/recurring/${btn.dataset.rid}`);
_renderTab();
});
});
});
}
function _showRecurringForm(r, onSave) {
const today = new Date().toISOString().slice(0, 10);
const katOptions = [
{ id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' },
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' },
{ id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' },
].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>`
).join('');
const body = `
<form id="exp-recurring-form">
<div class="form-group">
<label class="form-label">Kategorie</label>
<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>
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
<select class="form-control" name="haeufigkeit">
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Startdatum</label>
<input class="form-control" type="date" name="startdatum"
value="${r?.startdatum || today}" required>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
<select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="notiz"
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]');
const fd = UI.formData(e.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
await UI.asyncButton(btn, async () => {
if (r) {
await API.patch(`/expenses/recurring/${r.id}`, payload);
} else {
await API.post('/expenses/recurring', payload);
}
UI.modal.close();
onSave?.();
});
});
}
// ----------------------------------------------------------
// TAB: STATISTIK
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const s = _summary;
const gesamtJahr = s.gesamt_jahr || 1;
// Jahres-Aufteilung nach Kategorien (als Balken-Reihen)
const katBalken = KATEGORIEN
.filter(k => (s.jahr[k.id] || 0) > 0)
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
.map(k => {
const val = s.jahr[k.id] || 0;
const pct = Math.round((val / gesamtJahr) * 100);
return `
<div class="exp-stat-row">
<div class="exp-stat-label">
<span class="exp-stat-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
${k.label}
</div>
<div class="exp-stat-bar-wrap">
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
</div>
<div class="exp-stat-pct">${pct}%</div>
<div class="exp-stat-val">${_fmt(val)}</div>
</div>`;
}).join('');
// Monats-Balken mit gestapelten Top-2-Kategorien
const heute = new Date();
const jahrStr = heute.getFullYear().toString();
// Pro Monat: Summe je Kategorie berechnen
const monatKatMap = {}; // { monat: { katId: summe } }
_entries
.filter(e => e.datum.startsWith(jahrStr))
.forEach(e => {
const m = parseInt(e.datum.split('-')[1]);
if (!monatKatMap[m]) monatKatMap[m] = {};
monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag;
});
const monatTotalMap = {};
Object.entries(monatKatMap).forEach(([m, katObj]) => {
monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0);
});
const maxMonat = Math.max(...Object.values(monatTotalMap), 1);
const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const monatsBalken = MONATE.map((label, i) => {
const mi = i + 1;
const total = monatTotalMap[mi] || 0;
const pct = Math.round((total / maxMonat) * 100);
const isAktiv = mi === (heute.getMonth() + 1);
// Top-2-Kategorien für gestapelten Balken
let stackHtml = '';
if (total > 0 && monatKatMap[mi]) {
const sorted = Object.entries(monatKatMap[mi])
.sort((a, b) => b[1] - a[1])
.slice(0, 2);
// Gesamthöhe = pct%, verteile anteilig auf Top-2
let rest = pct;
const segments = sorted.map(([katId, val], idx) => {
const k = _kat(katId);
const segPct = idx < sorted.length - 1
? Math.round((val / total) * pct)
: rest;
rest -= segPct;
return `<div class="exp-stack-seg" style="height:${segPct}%;background:${k.color}" title="${k.label}: ${_fmt(val)}"></div>`;
});
stackHtml = segments.join('');
} else {
stackHtml = `<div class="exp-stack-seg" style="height:${pct}%;background:var(--c-border)"></div>`;
}
return `
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
<div class="exp-bar-track exp-bar-track--stack">
${stackHtml}
</div>
<div class="exp-bar-label">${label}</div>
</div>`;
}).join('');
// Donut-Übersicht (CSS-gradient)
const donutHtml = _donutHtml(s, gesamtJahr);
el.innerHTML = `
<div class="exp-hero-card exp-hero-card--sm">
<div class="exp-hero-label">Gesamt dieses Jahr</div>
<div class="exp-hero-betrag">${_fmt(s.gesamt_jahr)}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
</div>
${donutHtml}
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-pie')} Aufteilung nach Kategorie</div>
<div class="exp-stat-rows">
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
</div>
</div>
<div style="height:80px"></div>
`;
}
// Donut via CSS conic-gradient
function _donutHtml(s, gesamt) {
const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0);
if (!aktiveKat.length) return '';
// Stops für conic-gradient berechnen
let offset = 0;
const stops = [];
aktiveKat.forEach(k => {
const pct = (s.jahr[k.id] || 0) / gesamt * 100;
stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`);
offset += pct;
});
const gradient = `conic-gradient(${stops.join(', ')})`;
const legendeItems = aktiveKat
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
.map(k => {
const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100);
return `
<div class="exp-donut-legend-item">
<span class="exp-donut-dot" style="background:${k.color}"></span>
<span class="exp-donut-legend-label">${k.label}</span>
<span class="exp-donut-legend-pct">${pct}%</span>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-pie')} Kategorien-Verteilung</div>
<div class="exp-donut-wrap">
<div class="exp-donut" style="background:${gradient}">
<div class="exp-donut-hole"></div>
</div>
<div class="exp-donut-legend">${legendeItems}</div>
</div>
</div>`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const selKat = entry?.kategorie || 'sonstiges';
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
// Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => `
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
<span class="exp-kat-tile-label">${k.label}</span>
</label>`).join('');
const body = `
<form id="${formId}" autocomplete="off">
<div class="form-group">
<label class="form-label">Kategorie</label>
<div class="exp-kat-grid">${katKacheln}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Betrag</label>
<div class="exp-betrag-wrap">
<span class="exp-betrag-prefix"></span>
<input type="number" name="betrag" class="form-control exp-betrag-input"
value="${entry?.betrag || ''}" min="0.01" step="0.01"
placeholder="0,00" required>
</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label>
<input type="date" name="datum" class="form-control"
value="${entry?.datum || today}" required>
</div>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span class="form-label-hint">(optional)</span></label>
<select name="dog_id" class="form-control">
<option value=""> kein Hund </option>${dogOptions}
</select>
</div>` : ''}
<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 || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div>
${!isEdit ? `
<div class="exp-repeat-section">
<label class="exp-repeat-toggle">
<input type="checkbox" id="exp-wiederholen" name="wiederholen">
<span class="exp-repeat-toggle-box"></span>
<span>${UI.icon('arrows-clockwise')} Automatisch wiederholen</span>
</label>
<div id="exp-repeat-opts" style="display:none;margin-top:var(--space-3)">
<select name="haeufigkeit" class="form-control">
<option value="monatlich">Monatlich</option>
<option value="quartalsweise">Quartalsweise (alle 3 Monate)</option>
<option value="jaehrlich" selected>Jährlich</option>
</select>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0">
Der Betrag wird automatisch zum Fälligkeitstermin eingetragen.
</p>
</div>
</div>` : ''}
</form>`;
const footer = isEdit ? `
<button type="button" class="btn btn-ghost btn-sm" id="exp-delete-btn"
style="color:var(--c-danger);margin-right:auto">
${UI.icon('trash')}
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
` : `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
`;
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
// Kategorie-Kacheln interaktiv
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
tile.addEventListener('click', () => {
modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel'));
tile.classList.add('exp-kat-tile--sel');
});
});
// Wiederholen-Toggle
modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => {
modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none';
});
if (isEdit) {
modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => {
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
try {
await API.del(`/expenses/${entry.id}`);
UI.modal.close();
UI.toast.success('Ausgabe gelöscht.');
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
}
modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = UI.formData(ev.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
datum: fd.datum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
try {
if (isEdit) {
await API.patch(`/expenses/${entry.id}`, payload);
UI.toast.success('Ausgabe aktualisiert.');
} else {
await API.post('/expenses', payload);
// Auch als Dauerauftrag anlegen wenn gewünscht
if (fd.wiederholen) {
await API.post('/expenses/recurring', {
...payload,
haeufigkeit: fd.haeufigkeit || 'jaehrlich',
startdatum: fd.datum,
});
UI.toast.success('Ausgabe + Dauerauftrag gespeichert.');
} else {
UI.toast.success('Ausgabe gespeichert.');
}
}
UI.modal.close();
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
}
});
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _invalidateCache() {
_summary = null;
_entries = [];
_statsData = null;
}
function _fmt(val) {
return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function _fmtShort(val) {
if (!val) return '0 €';
if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€';
return Math.round(val) + ' €';
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();

View file

@ -66,6 +66,7 @@ window.Page_forum = (() => {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
}
@ -98,15 +99,17 @@ 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}">${_esc(k.label)}</button>
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
</div>
<!-- Rechte Spalte: Suche + Threads -->
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
<div class="forum-main-col">
<div id="forum-hdm-card"></div>
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
@ -127,6 +130,23 @@ window.Page_forum = (() => {
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
_tabsEl.addEventListener('mouseenter', e => {
const btn = e.target.closest('.by-tab');
const span = btn?.querySelector('.by-tab-text');
if (!span) return;
const style = getComputedStyle(btn);
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const overflow = span.scrollWidth - (btn.clientWidth - padH);
if (overflow <= 2) return;
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
span.classList.add('scrolling');
}, true);
_tabsEl.addEventListener('mouseleave', e => {
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
if (span) span.classList.remove('scrolling');
}, true);
// Tab-Klicks
_tabsEl.addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
@ -175,6 +195,177 @@ window.Page_forum = (() => {
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
}
// ----------------------------------------------------------
// Hund des Monats — Kachel + Modal
// ----------------------------------------------------------
async function _loadHdmCard() {
const card = document.getElementById('forum-hdm-card');
if (!card) return;
try {
const data = await API.get('/movies/hund-des-monats');
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
.format(new Date(+year, +month - 1, 1));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
: 'Sei der Erste!';
card.innerHTML = `
<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-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div>
</div>
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
</div>`;
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
} catch {
// Kachel bleibt leer bei Fehler
}
}
async function _openHdmModal(data) {
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
const topList = data.top?.length
? 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]) : '';
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>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
</div>`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
const voteHint = !_appState.user
? `<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um abstimmen zu können.
</p>
</div>`
: `<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
<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)">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
</div>
</div>`;
const body = `
<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>
${voteHint}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
if (!_appState.user) return;
// Kandidaten laden und rendern
let _kandidaten = [];
const _renderKandidaten = (list) => {
const grid = document.getElementById('hdm-kandidaten-grid');
if (!grid) return;
if (!list.length) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
return;
}
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]) : '';
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>` : ''}
<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'}
</button>
</div>`;
}).join('');
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
data.user_vote = dogId;
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
};
try {
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
} catch {
document.getElementById('hdm-kandidaten-grid').innerHTML =
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
return;
}
_renderKandidaten(_kandidaten);
document.getElementById('hdm-search')?.addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
_renderKandidaten(q
? _kandidaten.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.rasse || '').toLowerCase().includes(q))
: _kandidaten
);
});
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------

View file

@ -6,11 +6,13 @@
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _favoritVet = null;
let _healthDocs = [];
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
@ -20,7 +22,6 @@ window.Page_health = (() => {
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
@ -150,6 +151,9 @@ window.Page_health = (() => {
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
</button>
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
@ -162,6 +166,8 @@ window.Page_health = (() => {
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
.addEventListener('click', _showKiTierarzt);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
@ -170,6 +176,7 @@ window.Page_health = (() => {
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
_loadMeinTierarzt();
}
// ----------------------------------------------------------
@ -342,6 +349,16 @@ window.Page_health = (() => {
} catch (err) {
_data['gewicht_chart'] = [];
}
try {
_favoritVet = await API.tieraerzte.myFavorite();
} catch (err) {
_favoritVet = null;
}
try {
_healthDocs = await API.healthDocs.list(dogId);
} catch (err) {
_healthDocs = [];
}
}
// ----------------------------------------------------------
@ -362,7 +379,6 @@ window.Page_health = (() => {
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'symptomcheck': _renderSymptomCheck(content); break;
}
_bindTabEvents(content);
@ -901,7 +917,8 @@ window.Page_health = (() => {
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
${_renderBefundeSection()}`;
}
// ----------------------------------------------------------
@ -957,6 +974,32 @@ window.Page_health = (() => {
// Praxis hinzufügen
content.querySelector('[data-action="add-praxis"]')
?.addEventListener('click', () => _showPraxForm(null));
// Favorit-Toggle für Praxen
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const vetId = parseInt(btn.dataset.praxisId);
await UI.asyncButton(btn, async () => {
const res = await API.tieraerzte.toggleFavorite(vetId);
if (res.is_favorite) {
_favoritVet = _praxen.find(p => p.id === vetId) || null;
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
} else {
_favoritVet = null;
UI.toast.success('Favorit entfernt.');
}
// is_favorite in _praxen aktualisieren
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
const elFav = _container.querySelector('#health-mein-tierarzt');
if (elFav) _renderMeinTierarztKachel(elFav);
_renderTab();
});
});
});
// Befunde & Dokumente
if (_activeTab === 'dokument') {
_bindBefundeEvents(content);
}
}
// ----------------------------------------------------------
@ -1597,7 +1640,9 @@ window.Page_health = (() => {
action: addBtn
});
const renderCard = p => `
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
@ -1626,17 +1671,40 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
</div>
</div>
</div>
`;
`};
const favCard = _favoritVet ? `
<div style="margin-bottom:var(--space-4)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
${UI.icon('heart')} Mein Tierarzt
</div>
${renderCard(_favoritVet)}
</div>` : '';
const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id);
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
${addBtn}
</div>
${favCard}
<div class="health-list">
${aktive.map(renderCard).join('')}
${ohneGesetzt.map(renderCard).join('')}
${inaktive.length ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
border-top:1px solid var(--c-border)">
@ -2156,6 +2224,306 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// MEIN TIERARZT — Kachel
// ----------------------------------------------------------
async function _loadMeinTierarzt() {
const el = _container.querySelector('#health-mein-tierarzt');
if (!el) return;
_renderMeinTierarztKachel(el);
}
function _renderMeinTierarztKachel(el) {
if (!el) return;
const vet = _favoritVet;
const adresse = vet
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
: '';
el.innerHTML = `
<div style="margin:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Mein Tierarzt
</div>
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.6rem;flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
${vet ? `
<div class="health-card-title">${_esc(vet.name)}</div>
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
${vet.telefon ? `
<div style="margin-top:var(--space-2)">
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
</a>
</div>` : ''}
` : `
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch kein Tierarzt als Favorit gespeichert.
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
id="health-suche-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
</button>
`}
</div>
${vet ? `
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
</button>
` : ''}
</div>
</div>
`;
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
App.navigate('map', { filter: 'tierarzt' });
});
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await UI.asyncButton(btn, async () => {
await API.tieraerzte.toggleFavorite(_favoritVet.id);
_favoritVet = null;
const elAgain = _container.querySelector('#health-mein-tierarzt');
if (elAgain) _renderMeinTierarztKachel(elAgain);
UI.toast.success('Tierarzt-Favorit entfernt.');
});
});
}
// ----------------------------------------------------------
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
// ----------------------------------------------------------
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
function _renderBefundeSection() {
const dog = _appState.activeDog;
const docs = _healthDocs;
const DOC_ICONS = {
blutbild: 'drop',
roentgen: 'file-text',
rezept: 'note',
impfausweis:'certificate',
sonstiges: 'file-text',
};
const DOC_LABELS = {
blutbild: 'Blutbild',
roentgen: 'Röntgen',
rezept: 'Rezept',
impfausweis:'Impfausweis',
sonstiges: 'Sonstiges',
};
const uploadBtn = `
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
</button>`;
const items = docs.length
? docs.map(doc => {
const icon = DOC_ICONS[doc.typ] || 'file-text';
const label = DOC_LABELS[doc.typ] || doc.typ;
const isImg = !['pdf'].includes(doc.file_type);
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
return `
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
<div class="health-card-title">${_esc(doc.titel)}</div>
<div class="health-card-meta">
${_esc(label)}${datum ? ' · ' + datum : ''}
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
</div>
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
</div>`;
}).join('')
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
Noch keine Befunde hochgeladen.
</p>`;
return `
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde &amp; Dokumente
</div>
${uploadBtn}
</div>
<div class="health-list" id="health-docs-list">${items}</div>
</div>
`;
}
function _bindBefundeEvents(content) {
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
_showBefundUploadModal();
});
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const docId = parseInt(btn.dataset.docId);
const ok = window.confirm('Befund wirklich löschen?');
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.healthDocs.delete(docId);
_healthDocs = _healthDocs.filter(d => d.id !== docId);
_renderTab();
UI.toast.success('Befund gelöscht.');
});
});
});
}
function _showBefundUploadModal() {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const dog = _appState.activeDog;
UI.modal.open({
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
body: `
<form id="befund-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Dokuments *</label>
<select class="form-control" name="typ" required>
<option value=""> bitte wählen </option>
<option value="blutbild">Blutbild</option>
<option value="roentgen">Röntgen</option>
<option value="rezept">Rezept</option>
<option value="impfausweis">Impfausweis</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel" required
placeholder="z.B. Blutbild März 2026">
</div>
<div class="form-group">
<label class="form-label">Untersuchungsdatum</label>
<input class="form-control" type="date" name="datum"
value="${new Date().toISOString().slice(0,10)}">
</div>
${aktivePraxen.length ? `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<select class="form-control" name="vet_id">
<option value=""> optional </option>
${aktivePraxen.map(p =>
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
).join('')}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Zusätzliche Infos (optional)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Datei * (PDF, JPG, PNG, WebP max. 10 MB)</label>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
<input type="file" name="file" id="befund-file-input"
accept=".pdf,image/*"
required
style="position:absolute;opacity:0;width:1px;height:1px">
</label>
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
color:var(--c-text-secondary)"></div>
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
`,
});
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('befund-file-input')?.addEventListener('change', function () {
const preview = document.getElementById('befund-file-preview');
if (this.files?.length) {
const f = this.files[0];
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
} else {
preview.textContent = '';
}
});
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.querySelector('[form="befund-form"][type="submit"]');
const form = e.target;
const fd = UI.formData(form);
const fileInput = form.querySelector('[name="file"]');
const file = fileInput?.files?.[0];
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
if (file.size > 10 * 1024 * 1024) {
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
return;
}
await UI.asyncButton(btn, async () => {
const formData = new FormData();
formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ);
formData.append('titel', fd.titel);
formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', file);
try {
const doc = await API.healthDocs.upload(formData);
_healthDocs.unshift(doc);
UI.modal.close();
_renderTab();
UI.toast.success('Befund hochgeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
@ -2323,6 +2691,129 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-TIERARZTFRAGEN
// ----------------------------------------------------------
function _showKiTierarzt() {
const dog = _appState.activeDog;
const dogName = dog?.name || '';
const rasse = dog?.rasse || '';
const placeholder = dogName
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung
kein Ersatz für einen echten Tierarzt.
</p>
<div class="form-group">
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
placeholder="${_esc(placeholder)}"></textarea>
</div>
<div id="ki-tierarzt-result" style="display:none"></div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fff3cd;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#856404;
border:1px solid #ffc107">
<strong>&#9888;&#65039; Hinweis:</strong> Dies ist keine medizinische Diagnose.
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
document.getElementById('ki-tierarzt-submit-btn')
.addEventListener('click', async function () {
const btn = this;
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
const resultEl = document.getElementById('ki-tierarzt-result');
if (!symptom) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post('/ki/tierarzt', {
symptom,
dog_id: dog?.id || null,
dog_name: dogName || null,
rasse: rasse || null,
});
} catch (err) {
if (err.status === 429) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-warning);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-danger);
font-size:var(--text-sm)">
KI momentan nicht verfügbar. Bitte später versuchen.
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
return;
}
resultEl.style.display = '';
return;
}
const antwortHtml = _esc(result.antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
.replace(/\n/g, '<br>');
const restHtml = result.limit - result.anfragen_heute > 0
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
</p>`
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Tageslimit erreicht. Morgen wieder verfügbar.
</p>`;
resultEl.innerHTML = `
<div style="margin-top:var(--space-4);padding:var(--space-4);
background:var(--c-surface);border-radius:var(--radius-md);
border:1px solid var(--c-border)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
Einschätzung
</div>
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
${restHtml}
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fee2e2;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#991b1b;
border:1px solid #fca5a5">
<strong>&#9888;&#65039; Dies ist keine medizinische Diagnose.</strong>
Bei ernsthaften Symptomen sofort zum Tierarzt.
</div>`;
resultEl.style.display = '';
// Submit-Button ausblenden wenn Limit erschöpft
if (result.anfragen_heute >= result.limit) {
btn.disabled = true;
btn.textContent = 'Limit erreicht';
}
});
});
}
return { init, refresh, openNew, onDogChange };
})();

View file

@ -0,0 +1,269 @@
/* ============================================================
BAN YARO Social-Media-Job Bewerbung
============================================================ */
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>`;
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function _render() {
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
let existingApp = null;
let trialStatus = null;
if (_appState.user) {
try {
const r = await API.get('/jobs/my-application');
existingApp = r.application;
trialStatus = await API.get('/jobs/luna-trial-status');
} catch { /* ignorieren */ }
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Social-Media-Manager/in gesucht
</h1>
<p style="color:var(--c-text-secondary);margin:0">
Werde das Gesicht von Ban Yaro auf Instagram &amp; TikTok
</p>
</div>
<!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)">
${_infoRow(_ph('map-pin'), 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
${_infoRow(_ph('calendar-dots'), 'Umfang', '12 Posts pro Woche auf Instagram &amp; TikTok')}
${_infoRow(_ph('tag'), 'Vergütung', '50 € / Monat — wächst mit der Community')}
${_infoRow(_ph('robot'), 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
${_infoRow(_ph('star'), 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
</div>
</div>
</div>
<!-- Luna-Probezugang Teaser -->
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:24px;height:24px"><use href="/icons/phosphor.svg#robot"></use></svg>
Luna 14 Tage kostenlos testen
</div>
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
bevor du dich entscheidest.
</p>
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;vertical-align:middle"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
</div>
<!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Du hast einen Hund und liebst ihn sehr 🐕</li>
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
<li>Du schreibst gerne und authentisch auf Deutsch</li>
<li>Du hast Lust, eine junge App bekannt zu machen aus Überzeugung</li>
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
</ul>
</div>
</div>
<!-- Bewerbungsformular oder Status -->
${existingApp ? _renderStatus(existingApp) : _renderForm()}
</div>
`;
if (!existingApp) {
_bindForm();
}
}
function _infoRow(icon, label, text) {
return `
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="margin-top:1px">${icon}</div>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
</div>
</div>`;
}
function _renderStatus(app) {
const statusMap = {
pending: { icon: 'clock', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
reviewing: { icon: 'magnifying-glass', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
accepted: { icon: 'check-circle', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
rejected: { icon: 'x', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
};
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)">
<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>
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
</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>` : ''}
</div>`;
}
function _renderForm() {
const u = _appState.user;
return `
<div class="card">
<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)">
Jetzt bewerben
</h2>
<form id="jobs-form" novalidate>
<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>
</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>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
</div>
<div class="form-group" style="margin:0">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
</div>
</div>
<div class="form-group">
<label class="form-label">Instagram oder TikTok Handle *</label>
<div style="position:relative">
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted)">@</span>
<input class="form-control" type="text" name="social_handle"
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
</div>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Dein öffentliches Profil auf Instagram oder TikTok
</p>
</div>
<div class="form-group">
<label class="form-label">Warum du? *</label>
<textarea class="form-control" name="motivation" rows="5"
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
style="resize:vertical"></textarea>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Mindestens 80 Zeichen
</p>
</div>
<div class="form-group">
<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)">
<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.
</p>
</div>
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
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>,
bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''}
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Bewerbung absenden + Luna freischalten
</button>
</form>
</div>
</div>`;
}
function _bindForm() {
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
e.preventDefault();
if (window.App) App.navigate('settings');
});
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = new FormData(e.target);
// Dateien aus file-input übernehmen
const fileInput = document.getElementById('jobs-files');
if (fileInput?.files?.length) {
fd.delete('files');
for (const f of fileInput.files) fd.append('files', f);
}
await UI.asyncButton(btn, async () => {
const resp = await fetch('/api/jobs/apply', {
method: 'POST',
body: fd,
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
credentials: 'include',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler beim Absenden.');
}
const result = await resp.json();
if (result.luna_trial) {
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
// User-State aktualisieren damit Luna sofort zugänglich ist
if (_appState.user && window.API) {
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
}
} else {
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
}
await _render();
});
});
}
return { init };
})();

View file

@ -13,6 +13,9 @@ window.Page_movies = (() => {
let _filme = [];
let _activeTab = 'filme';
let _filter = 'alle';
let _typ = 'alle'; // alle | film | serie | doku
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
let _search = '';
// ----------------------------------------------------------
// INIT
@ -39,7 +42,6 @@ window.Page_movies = (() => {
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
@ -64,26 +66,54 @@ window.Page_movies = (() => {
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
// TAB 1: FILME
// ----------------------------------------------------------
async function _loadFilme() {
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
}
async function _renderFilme(content) {
try {
_filme = await API.get('/movies/filme');
await _loadFilme();
} catch {
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return;
}
content.innerHTML = `
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg>+</button>
<div class="movies-controls">
<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">
</div>
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
</div>
<div class="movies-filter-row" style="margin-top:var(--space-2)">
<button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'doku' ? ' movies-filter-btn--active' : ''}" data-typ="doku"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#camera"></use></svg> Dokus</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#list"></use></svg>
<select id="movies-sort" class="form-control" style="flex:1;font-size:var(--text-sm);padding:var(--space-2) var(--space-3)">
<option value="default" ${_sort==='default' ?'selected':''}>Empfohlen</option>
<option value="bewertung" ${_sort==='bewertung' ?'selected':''}>Community-Bewertung</option>
<option value="imdb" ${_sort==='imdb' ?'selected':''}>IMDb-Bewertung</option>
<option value="jahr_desc" ${_sort==='jahr_desc' ?'selected':''}>Neueste zuerst</option>
<option value="jahr_asc" ${_sort==='jahr_asc' ?'selected':''}>Älteste zuerst</option>
<option value="titel" ${_sort==='titel' ?'selected':''}>Titel AZ</option>
</select>
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
</div>
</div>
<div class="movie-grid" id="movie-grid"></div>
`;
@ -97,6 +127,31 @@ window.Page_movies = (() => {
});
});
content.querySelectorAll('.movies-type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
_typ = btn.dataset.typ;
content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
});
content.querySelector('#movies-search')?.addEventListener('input', e => {
_search = e.target.value.trim().toLowerCase();
_renderMovieGrid(content.querySelector('#movie-grid'));
});
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
_sort = e.target.value;
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
_renderMovieGrid(content.querySelector('#movie-grid'));
}
@ -106,7 +161,18 @@ window.Page_movies = (() => {
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
if (_search) {
list = list.filter(f =>
(f.titel || '').toLowerCase().includes(_search) ||
(f.hund_rasse || '').toLowerCase().includes(_search) ||
(f.genre || '').toLowerCase().includes(_search) ||
(f.beschreibung || '').toLowerCase().includes(_search)
);
}
const countEl = document.getElementById('movies-count');
if (countEl) countEl.textContent = `${list.length} Einträge`;
if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
@ -130,18 +196,25 @@ window.Page_movies = (() => {
function _movieCard(film) {
const stirbt = film.stirbt_der_hund;
const tag = stirbt
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> ACHTUNG: Der Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Der Hund überlebt</div>`;
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Hund überlebt</div>`;
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>` : '';
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</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>` : ''}
</div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div>
</div>
</div>

View file

@ -0,0 +1,708 @@
/* ============================================================
BAN YARO Playdate-Matching
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
============================================================ */
window.Page_playdate = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
let _userPos = null;
let _radius = 10;
let _dogs = [];
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE');
}
function _dogAvatar(foto_url, name, size = 48) {
const initials = _esc((name || '?').charAt(0).toUpperCase());
if (foto_url) {
return `<img src="${_esc(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
}
return `<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>`;
}
function _statusBadge(status) {
const map = {
pending: ['warning', 'Ausstehend'],
accepted: ['success', 'Angenommen'],
declined: ['danger', 'Abgelehnt'],
};
const [type, label] = map[status] || ['default', status];
const colors = {
warning: 'var(--c-warning, #f59e0b)',
success: 'var(--c-success, #10b981)',
danger: 'var(--c-danger, #ef4444)',
default: 'var(--c-text-muted)',
};
return `<span style="font-size:var(--text-xs);font-weight:600;
color:${colors[type]};padding:2px 8px;border-radius:999px;
background:${colors[type]}18">${label}</span>`;
}
// ------------------------------------------------------------------
// INIT
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
_render();
_switchTab(_activeTab);
}
function refresh() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
_switchTab(_activeTab);
}
function onDogChange() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
if (_activeTab === 'listings') _loadListings();
}
// ------------------------------------------------------------------
// RENDER — Grundstruktur mit Tabs
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="playdate-layout">
<!-- Tabs -->
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
<button class="by-tab" data-tab="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests">
Anfragen
<span id="playdate-req-badge" style="display:none;margin-left:4px;
background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 6px;font-size:var(--text-xs);font-weight:700">0</span>
</button>
</div>
<!-- Tab-Inhalt -->
<div id="playdate-content"></div>
</div>
`;
document.getElementById('playdate-tabs').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
}
function _switchTab(tab) {
_activeTab = tab;
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
const content = document.getElementById('playdate-content');
if (!content) return;
if (tab === 'nearby') _renderNearby(content);
if (tab === 'listings') _renderListings(content);
if (tab === 'requests') _renderRequests(content);
}
// ------------------------------------------------------------------
// TAB: IN DER NÄHE
// ------------------------------------------------------------------
async function _renderNearby(el) {
el.innerHTML = `
<div>
<!-- Toolbar: Radius-Auswahl + Standort-Button -->
<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">
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span>
</div>
<select id="nearby-radius" class="form-select" style="width:auto;font-size:var(--text-sm)">
<option value="5" ${_radius===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${_radius===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${_radius===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${_radius===50 ? 'selected' : ''}>50 km</option>
</select>
<button class="btn btn-ghost btn-sm" id="nearby-locate-btn">
${UI.icon('crosshair')} Standort aktualisieren
</button>
</div>
<!-- Info-Hinweis -->
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-4);padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md)">
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
Dein genauer Standort bleibt privat es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
</div>
<!-- Ergebnisse -->
<div id="nearby-results">
<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">
Standort wird ermittelt
</p>
</div>
</div>
`;
document.getElementById('nearby-radius').addEventListener('change', e => {
_radius = parseInt(e.target.value, 10);
_loadNearby();
});
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
const btn = document.getElementById('nearby-locate-btn');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort aktualisiert';
await _loadNearby();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(btn, false);
}
});
if (!_userPos) {
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort bekannt';
} catch {
document.getElementById('nearby-results').innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
<p style="margin:var(--space-3) 0 var(--space-4)">
Standort konnte nicht automatisch ermittelt werden.<br>
Klicke auf "Standort aktualisieren".
</p>
</div>
`;
return;
}
}
await _loadNearby();
}
async function _loadNearby() {
if (!_userPos) return;
const resultsEl = document.getElementById('nearby-results');
if (!resultsEl) return;
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Suche…</p>`;
try {
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
if (!data || data.length === 0) {
resultsEl.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Niemand in der Nähe',
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
});
return;
}
resultsEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
${data.map(d => _nearbyCard(d)).join('')}
</div>
`;
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const toDogId = parseInt(btn.dataset.dogId, 10);
const dogName = btn.dataset.dogName;
_showRequestModal(toDogId, dogName);
});
});
} catch (err) {
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _nearbyCard(d) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(d.foto_url, d.dog_name, 56)}
<div style="flex:1;min-width:0">
<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>` : ''}
</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
</span>
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
</div>
${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
${_esc(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)}">
${UI.icon('paw-print')} Spielkamerad anfragen
</button>
</div>
`;
}
function _showRequestModal(toDogId, dogName) {
const formId = 'playdate-req-form';
UI.modal.open({
title: `Anfrage an ${dogName}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Nachricht (optional)</label>
<textarea id="req-nachricht" class="form-control" rows="3" maxlength="500"
placeholder="Hallo! Unsere Hunde könnten super zusammenpassen…"></textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="req-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="req-send-btn" form="${formId}">
${UI.icon('paper-plane-tilt')} Anfrage senden
</button>
`,
});
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('req-send-btn').addEventListener('click', async () => {
const btn = document.getElementById('req-send-btn');
const nachricht = document.getElementById('req-nachricht').value.trim();
await UI.asyncButton(btn, async () => {
const result = await API.post('/playdate/request', {
to_dog_id: toDogId,
nachricht: nachricht || null,
});
UI.modal.close();
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
// Zum Chat navigieren
if (result.conversation_id) {
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
}
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: MEINE INSERATE
// ------------------------------------------------------------------
async function _renderListings(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
await _loadListings(el);
}
async function _loadListings(el) {
const target = el || document.getElementById('playdate-content');
if (!target) return;
if (_dogs.length === 0) {
target.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch kein Hund',
text: 'Lege zuerst einen Hund in deinem Profil an.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
});
return;
}
// Listings für alle eigenen Hunde laden
const listings = {};
await Promise.all(_dogs.map(async dog => {
try {
const data = await API.get(`/playdate/my-listing/${dog.id}`);
listings[dog.id] = data;
} catch {
listings[dog.id] = null;
}
}));
target.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
</div>
`;
// Event-Delegation für alle Buttons
target.addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const dogId = parseInt(btn.dataset.dogId, 10);
const dog = _dogs.find(d => d.id === dogId);
if (action === 'edit') {
_showListingModal(dog, listings[dogId], async () => {
await _loadListings();
});
}
if (action === 'deactivate') {
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
try {
await API.del(`/playdate/listing/${dogId}`);
UI.toast.success('Inserat deaktiviert.');
await _loadListings();
} catch (err) {
UI.toast.error(err.message);
}
}
});
}
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
</div>
<span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px;
background:${isAktiv ? 'var(--c-success-subtle,#d1fae5)' : 'var(--c-surface-2)'};
color:${isAktiv ? 'var(--c-success,#10b981)' : 'var(--c-text-muted)'}">
${isAktiv ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
${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) + ' · ' : ''}
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>` : ''}
` : `
<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.
</p>
`}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-primary btn-sm"
data-action="edit" data-dog-id="${dog.id}">
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
</button>
${isAktiv ? `
<button class="btn btn-ghost btn-sm"
data-action="deactivate" data-dog-id="${dog.id}">
${UI.icon('x')} Deaktivieren
</button>` : ''}
</div>
</div>
`;
}
function _showListingModal(dog, existing, onSaved) {
const formId = 'listing-form';
UI.modal.open({
title: `Inserat für ${dog.name}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Ort / Standort</label>
<div style="display:flex;gap:var(--space-2)">
<input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München"
value="${_esc(existing?.ort_name || '')}">
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln">
${UI.icon('crosshair')}
</button>
</div>
<input type="hidden" id="listing-lat" value="${existing?.lat || ''}">
<input type="hidden" id="listing-lon" value="${existing?.lon || ''}">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln.
Nur der Ortsname wird für andere sichtbar nicht dein genauer Standort.
</div>
</div>
<div class="form-group">
<label class="form-label">Suchradius</label>
<select id="listing-radius" class="form-control">
<option value="5" ${(existing?.radius_km||10)===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${(existing?.radius_km||10)===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${(existing?.radius_km||10)===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${(existing?.radius_km||10)===50 ? 'selected' : ''}>50 km</option>
</select>
</div>
<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>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="listing-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="listing-save-btn">
${UI.icon('floppy-disk')} Speichern
</button>
`,
});
// GPS-Button
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
const gpsBtn = document.getElementById('listing-gps-btn');
UI.setLoading(gpsBtn, true);
try {
const pos = await API.getLocation();
document.getElementById('listing-lat').value = pos.lat;
document.getElementById('listing-lon').value = pos.lon;
// Reverse-Geocoding für Ortsname
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
{ cache: 'no-store' }
);
const geoData = await rev.json();
const a = geoData.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) document.getElementById('listing-ort').value = ort;
} catch {}
UI.toast.success('Standort ermittelt.');
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(gpsBtn, false);
}
});
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('listing-save-btn').addEventListener('click', async () => {
const btn = document.getElementById('listing-save-btn');
const lat = parseFloat(document.getElementById('listing-lat').value);
const lon = parseFloat(document.getElementById('listing-lon').value);
const ort = document.getElementById('listing-ort').value.trim();
const rad = parseInt(document.getElementById('listing-radius').value, 10);
const desc = document.getElementById('listing-beschreibung').value.trim();
if (!lat || !lon) {
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
return;
}
await UI.asyncButton(btn, async () => {
await API.put('/playdate/listing', {
dog_id: dog.id,
lat,
lon,
ort_name: ort || null,
radius_km: rad,
beschreibung: desc || null,
});
UI.modal.close();
UI.toast.success('Inserat gespeichert!');
onSaved?.();
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: ANFRAGEN
// ------------------------------------------------------------------
async function _renderRequests(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
try {
const data = await API.get('/playdate/requests');
const incoming = data.incoming || [];
const outgoing = data.outgoing || [];
// Badge aktualisieren
const pendingCount = incoming.filter(r => r.status === 'pending').length;
const badge = document.getElementById('playdate-req-badge');
if (badge) {
badge.textContent = pendingCount;
badge.style.display = pendingCount > 0 ? '' : 'none';
}
if (incoming.length === 0 && outgoing.length === 0) {
el.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch keine Anfragen',
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
});
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
${incoming.length > 0 ? `
<div>
<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)">
${incoming.map(r => _incomingCard(r)).join('')}
</div>
</div>` : ''}
${outgoing.length > 0 ? `
<div>
<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)">
${outgoing.map(r => _outgoingCard(r)).join('')}
</div>
</div>` : ''}
</div>
`;
// Button-Events (Accept/Decline)
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const reqId = parseInt(btn.dataset.reqId, 10);
const status = btn.dataset.status;
await UI.asyncButton(btn, async () => {
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
if (status === 'accepted' && result.conversation_id) {
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
} else {
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
}
await _renderRequests(el);
}, { errorMsg: null });
});
});
// Chat-Buttons
el.querySelectorAll('.req-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
App.navigate('chat', true);
});
});
} catch (err) {
el.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _incomingCard(r) {
const isPending = r.status === 'pending';
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
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)}"
</div>` : ''}
${isPending ? `
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen
</button>
<button class="btn btn-ghost btn-sm req-decline-btn"
data-req-id="${r.id}" data-status="declined">
${UI.icon('x')} Ablehnen
</button>
</div>` : `
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Zum Chat
</button>` : ''}
`}
</div>
`;
}
function _outgoingCard(r) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
"${_esc(r.nachricht)}"
</p>` : ''}
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Chat öffnen
</button>` : ''}
</div>
`;
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,188 @@
/* ============================================================
BAN YARO Tierfutter-Rückrufe
Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
============================================================ */
window.Page_recalls = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _recalls = [];
let _query = '';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_query = '';
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_recalls = [];
_query = '';
await _render();
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<!-- Warnbanner -->
<div class="recalls-warning-banner">
<svg class="ph-icon recalls-warning-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<p class="recalls-warning-text">
<strong>Hinweis:</strong> Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
</p>
</div>
<!-- Suchfeld -->
<div style="position:relative;margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);pointer-events:none">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input type="search" id="recalls-search" placeholder="Produkt, Gefahr oder Herkunft suchen…"
value="${UI.escape(_query)}"
style="width:100%;padding:var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3)*2 + 1.2rem);
border:1px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);background:var(--c-surface);color:var(--c-text);
box-sizing:border-box">
</div>
<!-- Ergebnis-Liste -->
<div id="recalls-list">${UI.skeleton(4)}</div>
`;
// Suchfeld-Handler
_container.querySelector('#recalls-search').addEventListener('input', (e) => {
_query = e.target.value.trim();
_renderList();
});
await _loadRecalls();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadRecalls() {
try {
const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
_recalls = await API.get(url);
} catch {
_container.querySelector('#recalls-list').innerHTML = UI.emptyState({
icon: 'warning-circle',
title: 'Rückrufe konnten nicht geladen werden',
text: 'Bitte versuche es später erneut.',
});
return;
}
_renderList();
}
// ----------------------------------------------------------
// LISTE RENDERN
// ----------------------------------------------------------
function _renderList() {
const listEl = _container.querySelector('#recalls-list');
if (!listEl) return;
const filtered = _query
? _recalls.filter(r => {
const q = _query.toLowerCase();
return (r.titel || '').toLowerCase().includes(q)
|| (r.produkt || '').toLowerCase().includes(q)
|| (r.gefahr || '').toLowerCase().includes(q)
|| (r.herkunft || '').toLowerCase().includes(q);
})
: _recalls;
if (!filtered.length) {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
listEl.innerHTML = UI.emptyState({
icon: UI.icon('check-circle'),
title: 'Aktuell keine Rückrufe',
text: `Letzte Prüfung: ${today}`,
});
return;
}
listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
}
// ----------------------------------------------------------
// EINZELNE KARTE
// ----------------------------------------------------------
function _cardHtml(r) {
const datum = r.datum
? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const meta = [
r.herkunft ? `<span>${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}</span>` : '',
datum ? `<span>${UI.icon('calendar-blank')} ${datum}</span>` : '',
r.quelle ? `<span style="text-transform:uppercase;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(r.quelle)}</span>` : '',
].filter(Boolean).join('<span style="color:var(--c-border)"> · </span>');
const linkHtml = r.url
? `<a href="${UI.escape(r.url)}" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);
color:var(--c-primary);text-decoration:none;margin-top:var(--space-1)">
${UI.icon('arrow-square-out')} Details auf RASFF
</a>`
: '';
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-left:4px solid #dc2626;border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)">
<!-- Titel -->
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
<use href="/icons/phosphor.svg#warning-octagon"></use>
</svg>
<strong style="font-size:var(--text-base);color:var(--c-text);line-height:1.4">
${UI.escape(r.produkt || r.titel)}
</strong>
</div>
<!-- Gefahr -->
${r.gefahr ? `
<p style="margin:0 0 var(--space-2) 0;font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${UI.escape(r.gefahr)}
</p>` : ''}
<!-- Meta-Zeile -->
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${meta}
</div>
<!-- Link -->
${linkHtml ? `<div style="padding-left:calc(var(--space-2) + 1.2rem)">${linkHtml}</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -263,6 +263,12 @@ window.Page_settings = (() => {
<span>Kalender abonnieren</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-worlds-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
@ -653,6 +659,11 @@ window.Page_settings = (() => {
}
});
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
@ -1238,6 +1249,49 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderVerifyPending(email) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
<div style="margin-bottom:var(--space-5)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
<p style="margin:0 0 var(--space-2)">
Wir haben einen Bestätigungslink an<br>
<strong>${email}</strong><br>
gesendet.
</p>
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
</p>
</div>
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
style="margin-bottom:var(--space-3)">
Link erneut senden
</button>
<button id="verify-back-btn" class="btn btn-ghost w-full"
style="color:var(--c-text-muted);font-size:var(--text-sm)">
Anderes Konto / Anmelden
</button>
</div>
`;
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Gesendet …';
try {
await API.post('/auth/resend-verification', { email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
} catch {
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
}
});
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
}
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
@ -1467,7 +1521,16 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const result = await API.auth.login(fd.email, fd.password);
let result;
try {
result = await API.auth.login(fd.email, fd.password);
} catch (err) {
if (err.message === 'EMAIL_NOT_VERIFIED') {
_renderVerifyPending(fd.email);
return;
}
throw err;
}
localStorage.setItem('by_token', result.token);
// User-Daten laden
@ -1583,22 +1646,12 @@ window.Page_settings = (() => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
_appState.dogs = [];
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
const greeting = _appState.user.is_founder_pending
? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
: _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding();
if (result.pending_verification) {
_renderVerifyPending(fd.email);
return;
}
});
});
}

View file

@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
if (!streak) return;
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
} else if (streak.current_streak > 1) {
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
}
}).catch(() => {});
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {

View file

@ -463,6 +463,8 @@ window.Page_welcome = (() => {
`).join('')}
</div>
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
<div class="wc-grid">
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}
}
// ----------------------------------------------------------
// STREAK-WIDGET
// ----------------------------------------------------------
async function _loadStreakWidget(dogId) {
const slot = _container.querySelector('#wc-streak-widget');
if (!slot) return;
let streak;
try {
streak = await API.get(`/streak/${dogId}`);
} catch { return; }
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
slot.innerHTML = _streakWidgetHTML(streak);
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
const modalEl = UI.modal.open({
title: '🔥 Trainings-Bestenliste',
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
});
let board;
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
const bodyEl = modalEl?.querySelector('.modal-body');
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
});
}
function _streakWidgetHTML(s) {
const cur = s.current_streak || 0;
const best = s.longest_streak || 0;
return `
<div class="wc-streak-card">
<div class="wc-streak-flame-wrap">
<span class="wc-streak-flame">🔥</span>
<span class="wc-streak-number">${cur}</span>
</div>
<div class="wc-streak-info">
<div class="wc-streak-label">Tage in Folge trainiert</div>
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
</div>
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
</div>`;
}
function _leaderboardHTML(rows) {
if (!rows || !rows.length) {
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
}
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rows.map((r, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
${r.foto_url
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<span style="font-size:1.1rem">🔥</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
</div>
</div>
`).join('')}
</div>`;
}
function _updateHeroFromDash(dash, dog) {
const heroBox = _container.querySelector('#wc-hero-box');
if (!heroBox) return;

View file

@ -0,0 +1,581 @@
/* ============================================================
BAN YARO Wetter (7-Tage-Wettervorhersage)
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
============================================================ */
window.Page_wetter = (() => {
// ----------------------------------------------------------
// KONSTANTEN
// ----------------------------------------------------------
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
const WMO_ICON = {
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
45:'cloud-fog', 48:'cloud-fog',
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
85:'cloud-snow', 86:'cloud-snow',
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
};
// Farben passend zum Wetter (für Icon-Tinting)
const WMO_COLOR = {
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
45:'#94A3B8', 48:'#94A3B8',
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
85:'#7DD3FC', 86:'#38BDF8',
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
};
function _wmoIcon(code, size = '2rem', extraStyle = '') {
const name = WMO_ICON[code] || 'cloud';
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
return `<svg class="ph-icon" aria-hidden="true"
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
<use href="/icons/phosphor.svg#${name}"></use>
</svg>`;
}
const WMO_DESC = {
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
45:'Nebel', 48:'Gefrierender Nebel',
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
};
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _data = null;
let _selDay = 0;
let _loading = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div id="wttr-body">
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
<p style="color:var(--c-text-secondary)">Standort wird ermittelt</p>
</div>
</div>
`;
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
await _loadData(pos.lat, pos.lon);
} catch {
_showLocationError();
}
}
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
</p>
<button class="btn btn-primary" id="wttr-btn-retry">
${UI.icon('map-pin')} Nochmal versuchen
</button>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadData(lat, lon) {
if (_loading) return;
_loading = true;
try {
_data = await API.weather.forecast(lat, lon);
_selDay = 0;
_renderWeather();
} catch {
const body = _container.querySelector('#wttr-body');
if (body) body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)"></div>
<h3 style="margin-bottom:var(--space-2)">Wetter nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
Die Wetterdaten konnten nicht geladen werden.
</p>
<button class="btn btn-primary" id="wttr-btn-reload">
${UI.icon('arrow-clockwise')} Erneut laden
</button>
</div>
`;
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
refresh();
});
} finally {
_loading = false;
}
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _renderWeather() {
const body = _container.querySelector('#wttr-body');
if (!body || !_data) return;
const days = _data.days || [];
if (!days.length) return;
body.innerHTML = `
<!-- 7-Tage-Strip -->
<div id="wttr-strip-wrap"
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
margin-bottom:var(--space-4);
scrollbar-width:none">
<div id="wttr-strip"
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
${days.map((d, i) => _dayCard(d, i)).join('')}
</div>
</div>
<!-- Detail-Card -->
<div id="wttr-detail" class="section-card"
style="margin-bottom:var(--space-4)">
</div>
<!-- Hunde-Wetter -->
<div id="wttr-dog" class="section-card">
</div>
`;
// Strip-Klick-Events
body.querySelectorAll('[data-wttr-day]').forEach(card => {
card.addEventListener('click', () => {
_selDay = parseInt(card.dataset.wttrDay);
_updateStrip();
_renderDetail();
_renderDog();
});
});
_renderDetail();
_renderDog();
}
// ----------------------------------------------------------
// STRIP AKTUALISIEREN (aktiver Tag)
// ----------------------------------------------------------
function _updateStrip() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
const days = _data?.days || [];
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
const active = i === _selDay;
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
card.style.color = active ? '#fff' : 'var(--c-text)';
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
card.style.transform = active ? 'translateY(-2px)' : '';
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
// Temperatur-Farbe im aktiven Zustand
const tempEl = card.querySelector('.wttr-temp');
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const precipEl = card.querySelector('.wttr-precip');
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
});
}
// ----------------------------------------------------------
// TAG-KARTE (Strip)
// ----------------------------------------------------------
function _dayCard(d, i) {
const active = i === _selDay;
const dateObj = new Date(d.date);
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
const col = active ? '#fff' : 'var(--c-text)';
const shadow = active
? '0 4px 12px rgba(196,132,58,0.3)'
: '0 1px 3px rgba(0,0,0,0.07)';
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
const transform = active ? 'translateY(-2px)' : '';
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
return `
<div data-wttr-day="${i}"
style="display:flex;flex-direction:column;align-items:center;
min-width:72px;padding:var(--space-3) var(--space-2);
border-radius:var(--radius);border:1.5px solid ${border};
background:${bg};color:${col};cursor:pointer;
box-shadow:${shadow};transform:${transform};
transition:all .15s;user-select:none">
<span style="font-size:var(--text-xs);font-weight:600;
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
<span class="wttr-temp"
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
</span>
<span class="wttr-precip"
style="font-size:10px;color:${textMut};margin-top:2px">
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
</span>
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-CARD
// ----------------------------------------------------------
function _renderDetail() {
const el = _container.querySelector('#wttr-detail');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const desc = WMO_DESC[d.weathercode] || '';
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
const bft = _beaufort(d.wind_kmh ?? 0);
const windDir = d.wind_dir_deg ?? 0;
const compass = d.wind_dir ?? _compass(windDir);
// Sunrise/Sunset Balken
const now = new Date();
const sunriseStr = d.sunrise || '';
const sunsetStr = d.sunset || '';
let sunPct = 0;
if (sunriseStr && sunsetStr) {
const [rH, rM] = sunriseStr.split(':').map(Number);
const [sH, sM] = sunsetStr.split(':').map(Number);
const riseMin = rH * 60 + rM;
const setMin = sH * 60 + sM;
const curMin = now.getHours() * 60 + now.getMinutes();
sunPct = _selDay === 0
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
: 0;
}
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
${_wmoIcon(d.weathercode, '3.5rem')}
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
${Math.round(d.temp_max)}°
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
/ ${Math.round(d.temp_min)}°
</span>
</div>
${d.feels_max != null ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
</div>` : ''}
</div>
</div>
<!-- Sonnenaufgang / -untergang -->
${sunriseStr && sunsetStr ? `
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-1)">
<span style="display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
${_esc(sunriseStr)}
</span>
<span style="display:flex;align-items:center;gap:4px">
${_esc(sunsetStr)}
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${sunPct}%;
background:linear-gradient(90deg,#f97316,#facc15);
border-radius:999px;transition:width .4s"></div>
</div>
</div>` : ''}
<!-- Wind -->
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:var(--c-bg-card);border:1px solid var(--c-border);
margin-bottom:var(--space-3)">
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
${UI.icon('arrow-up')}
</span>
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:600">
${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(bft)}</div>
</div>
${d.precip_sum != null ? `
<div style="text-align:right">
<div style="font-size:var(--text-sm);font-weight:600">
${d.precip_sum} mm
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Niederschlag</div>
</div>` : ''}
</div>
<!-- UV-Index -->
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:4px">
<span style="color:var(--c-text-secondary)">UV-Index</span>
<span style="font-weight:600;color:${uvColor}">
${d.uv_index ?? 0} ${_esc(uvLabel)}
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${uvPct}%;background:${uvColor};
border-radius:999px;transition:width .4s"></div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// HUNDE-WETTER
// ----------------------------------------------------------
function _renderDog() {
const el = _container.querySelector('#wttr-dog');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
margin-bottom:var(--space-4)">
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Hunde-Wetter
</h3>`;
// Asphalt-Temperatur
if (d.asphalt_temp != null) {
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
html += `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${aspColor}1a;border:1px solid ${aspColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
Asphalt ~${Math.round(d.asphalt_temp)}°C ${_esc(aspText)}
</div>
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(aspAdvice)}
</div>` : ''}
</div>
</div>
`;
}
// Pfoten-Kälteschutz
if (d.paw_cold) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#3b82f61a;border:1px solid #3b82f655;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Kälteschutz für Pfoten:</strong>
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
</div>
</div>
`;
}
// Gewitter
if (d.thunderstorm) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#f59e0b1a;border:1px solid #f59e0b55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Gewitter erwartet:</strong>
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
</div>
</div>
`;
}
// Pollenflug
const pollen = d.pollen;
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
const pollenEntries = Object.entries(pollen)
.filter(([, v]) => v != null && v.level > 0);
if (pollenEntries.length) {
html += `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
Pollenflug
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${pollenEntries.map(([key, lvlObj]) => {
const col = _pollenColor(lvlObj?.level ?? 0);
const name = _POLLEN_NAMES[key] || key;
const lbl = lvlObj?.label || '';
return `<span style="display:inline-flex;align-items:center;gap:4px;
font-size:var(--text-xs);border-radius:999px;
padding:3px 10px;background:${col}22;
border:1px solid ${col}55;color:${col};font-weight:600">
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
${_esc(name)}: ${_esc(lbl)}
</span>`;
}).join('')}
</div>
</div>
`;
}
}
// Zecken
if (d.zecken != null) {
const [tickLabel, tickColor] = _tickLevel(d.zecken);
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${tickColor}1a;border:1px solid ${tickColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
<div style="flex:1">
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
${_esc(tickLabel)}
</span>
</div>
</div>
`;
}
// Wenn keine Hunde-Daten vorhanden
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
html += `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Keine besonderen Hinweise für heute.
</p>
`;
}
el.innerHTML = html;
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN — Wetter
// ----------------------------------------------------------
function _beaufort(kmh) {
if (kmh < 2) return 'Windstille';
if (kmh < 12) return 'leicht';
if (kmh < 29) return 'mäßig';
if (kmh < 50) return 'frisch';
if (kmh < 62) return 'stark';
if (kmh < 75) return 'stürmisch';
return 'Sturm';
}
function _uvLabel(uv) {
if (uv <= 2) return ['niedrig', '#4CAF50'];
if (uv <= 5) return ['mittel', '#FFC107'];
if (uv <= 7) return ['hoch', '#FF9800'];
if (uv <= 10) return ['sehr hoch', '#F44336'];
return ['extrem', '#9C27B0'];
}
function _compass(deg) {
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
return dirs[Math.round(deg / 45) % 8];
}
function _asphaltLevel(temp) {
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
if (temp < 50) return ['leicht erwärmt', '#FFC107',
'Kurze Kontaktzeiten sind unbedenklich.'];
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
}
function _pollenColor(level) {
if (level === 0) return '#9E9E9E';
if (level === 1) return '#4CAF50';
if (level === 2) return '#FFC107';
if (level === 3) return '#FF9800';
return '#F44336'; // level 4+
}
function _tickLevel(risk) {
const r = (risk || '').toLowerCase();
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
if (r === 'mittel') return ['mittel', '#FF9800'];
return ['hoch', '#F44336'];
}
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -255,6 +255,15 @@ window.Page_wiki = (() => {
<option value="">Alle Gruppen</option>
</select>
</div>
<div style="padding:0 0 var(--space-3)">
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
style="font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Welche Rasse ist das? Foto analysieren
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="wiki-rasse-foto-input" style="display:none">
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
@ -264,6 +273,9 @@ window.Page_wiki = (() => {
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Rassen-Erkennung per KI
_bindWikiRasseErkennung(el);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
@ -1265,6 +1277,130 @@ window.Page_wiki = (() => {
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
// ----------------------------------------------------------
function _bindWikiRasseErkennung(el) {
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
const fileInput = el.querySelector('#wiki-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
return;
}
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origHtml;
_showWikiRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
}
});
}
function _showWikiRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
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)">
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>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
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>
<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.wiki_slug ? `
<div style="margin-top:var(--space-3)">
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
Im Wiki nachschlagen
</button>
</div>` : ''}
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<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>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('wiki-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
});
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

1071
backend/static/js/worlds.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,16 +3,16 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Check, Giftköder-Alarm — DSGVO-konform, ohne App Store.">
<meta name="keywords" content="Hunde App, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community">
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/info">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
<meta property="og:description" content="Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Giftköder-Alarm, Gassi-Community — alles in einer DSGVO-konformen App ohne App Store.">
<meta property="og:title" content="Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz">
<meta property="og:description" content="Tagebuch, Giftköder-Alarm, KI-Training, Forum, Wurfbörse, Stammbaum, Inzucht-Check — alles in einer DSGVO-konformen App ohne App Store. Kostenlos.">
<meta property="og:url" content="https://banyaro.app/info">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
@ -20,8 +20,8 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
<meta name="twitter:description" content="Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check — und alles rund um deinen Hund. Kostenlos, DSGVO-konform.">
<meta name="twitter:title" content="Ban Yaro — Die Hunde-App für DACH">
<meta name="twitter:description" content="Giftköder-Alarm, KI-Training, Forum, Wurfbörse, Stammbaum, Inzucht-Check — kostenlos, DSGVO-konform, ohne App Store.">
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
<!-- Structured Data -->
@ -50,42 +50,53 @@
"url": "https://banyaro.app"
},
"featureList": [
"Digitales Hunde-Tagebuch mit Fotos und GPS",
"Digitales Hunde-Tagebuch mit Fotos, Videos und GPS",
"Kalender-, Karten- und Medien-Ansicht im Tagebuch",
"Digitaler Impfpass und Gesundheitsakte",
"Pflege-System mit 43 rassenspezifischen Tipps in 10 Kategorien",
"Giftköder-Alarm mit Push-Benachrichtigungen",
"Zecken-Warnung regelbasiert (Saison + Temperatur)",
"Giftköder-Alarm mit GPS und Push-Benachrichtigungen",
"Zecken-Warnung regelbasiert (Saison und Temperatur)",
"Wetter-Chip in der App (Open-Meteo, ohne API-Key)",
"Gassi-Community und GPS-Routen",
"Hundesitting-Vermittlung",
"NFC-Halsband-Tags",
"Gassi-Community und GPS-Routen aufzeichnen",
"Täglicher Routenvorschlag (2, 4 oder 6 km via OpenRouteService)",
"Hundesitting-Vermittlung (nur 8% Provision)",
"NFC-Halsband-Tags mit öffentlichem Hunde-Profil",
"Forum öffentlich lesbar, schreiben nach E-Mail-Verifikation",
"1003 Hunderassen Wikipedia-grounded und KI-angereichert",
"Community-Fotos im Rassen-Wiki mit Bildrechte-Bestätigung und Moderation",
"Rassen-Quiz Passt diese Rasse zu mir?",
"Verlorener Hund Alarm",
"Forum für Hundebesitzer",
"Offline-Modus via Service Worker",
"Rassen-Wiki mit Community-Fotos und Moderation",
"Verlorener Hund Alarm mit GPS-Position",
"Offline-Modus via Service Worker (3 Stufen)",
"Symptom-Checker (KI, kostenlos)",
"Trainings-Tagebuch mit Einheiten-Logging (Wiederholungen, Erfolgsquote, Stimmung)",
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose",
"Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche",
"Trainings-Gamification: Streaks, Abzeichen, Trainingskalender",
"Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter",
"Wurfbörse — öffentliche Wurfankündigungen mit Filtersuche nach Rasse und Status",
"104 Trainingsübungen in Datenbank mit Schwierigkeitsgraden",
"Trainings-Logging: Wiederholungen, Erfolgsquote, Stimmung",
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen",
"Wöchentlicher KI-Lober jeden Montag",
"Trainings-Streaks und Trainingskalender",
"Kommandos und Fähigkeiten im Hundeprofil",
"Events-Kalender (Agility, Ausstellungen, lokale Veranstaltungen)",
"Wurfbörse mit Filtersuche nach Rasse und Status",
"Züchter-Profile mit verifizierten Gesundheitstests und Gentests",
"Stammbaum-Visualisierung bis 4 Generationen",
"Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung",
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
"Tierschutz-Check automatisch bei jeder Verpaarung — nicht abschaltbar",
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik-Erklärung, Paarungsanalyse",
"Datenexport als HTML und ODS — keine Datenfalle",
"Personalisierte Tagesroute via OpenRouteService — täglich neue Gassirunde mit 2/4/6 km Wahl",
"Übung des Tages — personalisiert aus dem persönlichen Trainingsfortschritt",
"Dashboard-Startseite mit Hundebild-Hero, Statistik-Chips und Feature-Karten"
"Tierschutz-Check automatisch bei jeder Verpaarung",
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik, Paarungsanalyse",
"Datenexport als HTML und ODS",
"Hunde-Filmdatenbank: 68 Filme, Serien und Dokumentationen sortier- und filterbar",
"Filmdatenbank-Feature: Stirbt der Hund? — Taschentuch-Warnung",
"Berühmte Hunde der Geschichte",
"Hund des Monats Community-Abstimmung",
"Push-Benachrichtigungen (VAPID, ohne Drittanbieter)",
"Freundschaften und Direktnachrichten",
"Social-Media-Manager Luna (KI-generierte Posts für Instagram und TikTok)",
"Notizblock mit KI-Analyse-Funktion",
"Erste-Hilfe-Ratgeber für häufige Notfälle",
"Hunde-Knigge (Begegnungen, ÖPNV, Leinenpflicht, Haftpflicht)",
"Admin-Panel mit Moderation, Outreach-Mailing und Statistiken"
],
"screenshot": "https://banyaro.app/icons/icon-512.png",
"softwareVersion": "2.2",
"datePublished": "2026-04-29",
"softwareVersion": "1.2.1",
"datePublished": "2026-05-01",
"areaServed": ["DE", "AT", "CH"],
"audience": {
"@type": "Audience",
@ -510,7 +521,7 @@
</div>
<div class="feature-card">
<span class="feature-icon">💬</span>
<div><h3>Forum</h3><p>Rassen-basierte Foren, KI-Zusammenfassungen langer Threads, Experten-Badge für Tierärzte und Trainer.</p><span class="feature-tag">Kostenlos</span></div>
<div><h3>Forum</h3><p>Öffentlich lesbar ohne Anmeldung. Kategorien nach Rasse, Region, Gesundheit und Erziehung. Schreiben nach E-Mail-Verifikation — für Qualität statt Spam.</p><span class="feature-tag">Kostenlos</span></div>
</div>
</div>
</div>
@ -528,7 +539,7 @@
</div>
<div class="feature-card">
<span class="feature-icon">🎬</span>
<div><h3>Hundefilme</h3><p>Filmdatenbank mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet in einen Film stolpern.</p><span class="feature-tag">Kostenlos</span></div>
<div><h3>Hunde-Filmdatenbank</h3><p>68 Filme, Serien und Dokumentationen — sortierbar nach Jahr, IMDb-Bewertung oder Community-Rating. Mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet stolpern.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🩹</span>
@ -604,13 +615,14 @@
<section id="vergleich">
<div class="container">
<h2>Ban Yaro vs. Konkurrenz</h2>
<p class="section-intro">Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform ohne US-Datenweitergabe.</p>
<p class="section-intro">Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform. Kein anderer Anbieter kombiniert Community, Training, Zucht und KI auf Deutsch.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Funktion</th>
<th>Ban Yaro</th>
<th>Hundeo (DE)</th>
<th>Dogorama</th>
<th>Tractive</th>
<th>PetDesk</th>
@ -621,12 +633,14 @@
<td>Kostenlos nutzbar</td>
<td class="check">✓ Ja</td>
<td>Begrenzt</td>
<td>Begrenzt</td>
<td class="cross">✗ Abo</td>
<td class="cross">✗ Nein</td>
</tr>
<tr>
<td>DSGVO / EU-Hosting</td>
<td class="check">✓ Ja</td>
<td class="check">✓ DE</td>
<td class="check">✓ DE</td>
<td class="cross">✗ Nein</td>
<td>Teilweise</td>
<td class="cross">✗ USA</td>
@ -637,6 +651,15 @@
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>KI-Hundetrainer</td>
<td class="check"></td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Giftköder-Alarm</td>
@ -644,54 +667,70 @@
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Digitaler Impfpass</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="check"></td>
</tr>
<tr>
<td>Gassi-Community</td>
<td>Forum & Community</td>
<td class="check"></td>
<td class="cross"></td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Gassi-Treffen & Community</td>
<td class="check"></td>
<td class="cross"></td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Wurfbörse & Zucht-Management</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Stammbaum & Inzucht-Check</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Hundesitting</td>
<td class="check">✓ (8%)</td>
<td class="check">✓ 8%</td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>NFC-Halsband-Tag</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Verlorener Hund Alarm</td>
<td class="check"></td>
<td class="cross"></td>
<td class="check"></td>
<td class="check">✓ (GPS)</td>
<td class="check">✓ GPS</td>
<td class="cross"></td>
</tr>
<tr>
<td>Rassen-Wiki (1003 Rassen, KI-angereichert)</td>
<td>Rassen-Wiki (1003 Rassen, KI)</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Pflege-Tipps rassenspezifisch</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
@ -700,13 +739,15 @@
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Täglicher Routenvorschlag (Gassirunde)</td>
<td>Täglicher Routenvorschlag</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
</tbody>
</table>
@ -809,6 +850,20 @@
<p>Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">🔐</span>
<div>
<h3>Aktive Sicherheit</h3>
<p>HSTS, Content-Security-Policy, Rate Limiting auf allen Endpunkten, Account-Lockout nach Fehlversuchen, E-Mail-Verifikation. Sicherheit by Default, nicht als Nachgedanke.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">🤖</span>
<div>
<h3>KI Made in Europe</h3>
<p>Alle KI-Funktionen laufen über Claude (Anthropic) — kein Training mit deinen Daten, kein Opt-out nötig, volle DSGVO-Konformität.</p>
</div>
</div>
</div>
</div>
</section>
@ -824,12 +879,16 @@
<footer>
<div class="container">
<p><strong style="color:white">Ban Yaro</strong> — Die deutschsprachige Hunde-Plattform</p>
<p style="margin-top:0.5rem">banyaro.app · banyaro.de · DSGVO-konform · Hosting in Deutschland</p>
<p style="margin-top:0.5rem">banyaro.app · DSGVO-konform · Hosting in Deutschland · Made with 🐾</p>
<div class="footer-links">
<a href="/">App öffnen</a>
<a href="/info">Über Ban Yaro</a>
<a href="/api/wiki/rassen">Hunde-Rassen</a>
<a href="/api/events">Events</a>
<a href="/wiki/rassen">Hunde-Rassen</a>
<a href="/knigge">Hunde-Knigge</a>
<a href="https://instagram.com/banyaro.app" rel="noopener" target="_blank">Instagram</a>
<a href="https://tiktok.com/@banyaro.app" rel="noopener" target="_blank">TikTok</a>
<a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a>
</div>
</div>
</footer>

View file

@ -1,6 +1,6 @@
{
"id": "/",
"version": "1.1.4",
"version": "1.3.0",
"name": "Ban Yaro — Die Hunde-Plattform",
"short_name": "Ban Yaro",
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.",

320
backend/static/presse.html Normal file
View file

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Presse Ban Yaro</title>
<meta name="description" content="Pressematerial, Logos und Screenshots für Redaktionen Ban Yaro Hunde-App">
<meta name="robots" content="noindex">
<link rel="icon" href="/icons/favicon.ico">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #b97c2a;
--primary-light: #f5ede0;
--text: #1a1a1a;
--muted: #666;
--border: #e5e0d8;
--bg: #faf9f7;
--white: #fff;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
/* Header */
header {
background: var(--white);
border-bottom: 1px solid var(--border);
padding: 1.25rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header img { width: 40px; height: 40px; border-radius: 10px; }
header .brand { font-size: 1.25rem; font-weight: 700; color: var(--text); }
header .brand span { color: var(--primary); }
header a { margin-left: auto; color: var(--primary); font-size: .9rem; text-decoration: none; }
/* Layout */
.container { max-width: 860px; margin: 0 auto; padding: 3rem 1.5rem; }
h1 { font-size: 2rem; font-weight: 800; margin-bottom: .5rem; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--text); }
.lead { color: var(--muted); margin-bottom: 3rem; font-size: 1.05rem; }
/* Sections */
section { margin-bottom: 3.5rem; }
.section-label {
font-size: .75rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--primary);
margin-bottom: .75rem;
}
/* Press release */
.pressemitteilung {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem 2.5rem;
}
.pressemitteilung .pm-meta {
font-size: .85rem; color: var(--muted); margin-bottom: 1.5rem;
}
.pressemitteilung h3 {
font-size: 1.4rem; font-weight: 800; margin-bottom: .35rem; line-height: 1.3;
}
.pressemitteilung .pm-sub {
font-size: .95rem; color: var(--muted); font-style: italic; margin-bottom: 1.5rem;
}
.pressemitteilung p { margin-bottom: 1rem; color: #333; }
.pressemitteilung blockquote {
border-left: 3px solid var(--primary);
padding-left: 1rem;
margin: 1.5rem 0;
color: #444;
font-style: italic;
}
/* Downloads */
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.download-card {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
color: var(--text);
transition: box-shadow .15s;
}
.download-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.download-card .thumb {
width: 100%; aspect-ratio: 9/16; object-fit: cover;
background: var(--primary-light);
display: block;
}
.download-card.logo-card .thumb {
aspect-ratio: 1; object-fit: contain; padding: 1.5rem;
}
.download-card .card-label {
padding: .6rem .85rem;
font-size: .8rem; color: var(--muted);
display: flex; align-items: center; justify-content: space-between;
}
.download-card .card-label span { font-weight: 600; color: var(--text); font-size: .85rem; }
.dl-icon { color: var(--primary); font-size: 1rem; }
/* Founder */
.founder-card {
display: flex; gap: 2rem; align-items: flex-start;
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
}
.founder-card img {
width: 140px; height: 140px;
border-radius: 50%; object-fit: cover; flex-shrink: 0;
border: 3px solid var(--primary-light);
}
.founder-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: .25rem; }
.founder-card .role { color: var(--primary); font-size: .9rem; margin-bottom: .75rem; }
.founder-card p { color: #444; font-size: .95rem; }
/* Facts */
.facts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.fact {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
}
.fact .fact-label { font-size: .8rem; color: var(--muted); margin-bottom: .25rem; }
.fact .fact-value { font-size: 1rem; font-weight: 700; }
/* Contact */
.contact-box {
background: var(--primary-light);
border-radius: var(--radius);
padding: 1.75rem 2rem;
}
.contact-box p { margin-bottom: .35rem; }
.contact-box a { color: var(--primary); font-weight: 600; text-decoration: none; }
/* Boilerplate */
.boilerplate {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem 2rem;
font-size: .9rem;
color: #444;
position: relative;
}
.copy-btn {
position: absolute; top: 1rem; right: 1rem;
background: var(--primary); color: white;
border: none; border-radius: 6px;
padding: .35rem .75rem; font-size: .75rem;
cursor: pointer; font-weight: 600;
}
.copy-btn:hover { opacity: .85; }
@media (max-width: 600px) {
.founder-card { flex-direction: column; }
.founder-card img { width: 100px; height: 100px; }
.pressemitteilung { padding: 1.5rem; }
h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<header>
<img src="/icons/icon-180.png" alt="Ban Yaro Icon">
<div class="brand">Ban <span>Yaro</span></div>
<a href="https://banyaro.app">← Zur App</a>
</header>
<div class="container">
<h1>Pressematerial</h1>
<p class="lead">Logos, Screenshots und Hintergrundinformationen für Redaktionen. Alle Materialien sind zur redaktionellen Verwendung freigegeben.</p>
<!-- Pressemitteilung -->
<section>
<div class="section-label">Pressemitteilung</div>
<div class="pressemitteilung">
<div class="pm-meta">Ebersberg, 1. Mai 2026 — zur sofortigen Veröffentlichung freigegeben</div>
<h3>Vom Gipfelfoto bis zum Giftköder-Alarm: App begleitet Hundehalter durch den ganzen Alltag</h3>
<p class="pm-sub">banyaro.app bündelt Tagebuch, Gesundheitsakte und Echtzeit-Warnungen in einer kostenlosen Hunde-App</p>
<p>Manche Gassi-Runden sind einfach unvergesslich — der erste Schnee, der perfekte Sonnenuntergang, die Stelle am Bach, an der der Hund immer ins Wasser springt. Andere hinterlassen Angst: Ein verdächtiges Häufchen am Wegesrand, ein Hund der plötzlich würgt.</p>
<p>Für beides gibt es jetzt eine App: <strong>banyaro.app</strong> ist eine kostenlose Hunde-App aus Bayern, die den ganzen Alltag mit Hund begleitet — von den schönsten Momenten bis zu den gefährlichen.</p>
<p>Im <strong>Hunde-Tagebuch</strong> lassen sich Fotos, Notizen und Erinnerungen sammeln, in der <strong>Gesundheitsakte</strong> Impftermine, Medikamente und Tierarztbesuche verwalten. Die interaktive <strong>Karte</strong> zeigt die besten Hundewiesen, Wasserstellen und Auslaufgebiete in der Umgebung — und die schönsten Routen für die nächste Gassi-Runde.</p>
<p>Der <strong>Giftköder-Alarm</strong> funktioniert nach dem Prinzip der Schwarmintelligenz: Wer einen verdächtigen Fund meldet, macht ihn sofort auf der Karte für alle anderen Hundehalter in der Region sichtbar. Keine Facebook-Gruppe, kein verschwundener Post — die Warnung bleibt dauerhaft abrufbar.</p>
<blockquote>„Ich wollte eine App bauen, die sich wie ein stiller Begleiter anfühlt — die im Hintergrund läuft, Erinnerungen sammelt und im Ernstfall sofort warnt. Kein App Store, keine Kosten, keine Werbung."<br><strong>— René Degelmann, Gründer</strong></blockquote>
<p>banyaro.app ist direkt unter <strong>banyaro.app</strong> erreichbar — ohne Installation, direkt im Smartphone-Browser.</p>
</div>
</section>
<!-- Kurzprofil / Boilerplate -->
<section>
<div class="section-label">Über Ban Yaro — Kurztext für Redaktionen</div>
<div class="boilerplate" id="boilerplate-text">
<button class="copy-btn" onclick="copyBoilerplate()">Kopieren</button>
<p>Ban Yaro ist eine kostenlose Hunde-App für den deutschsprachigen Raum. Die App läuft als Progressive Web App direkt im Smartphone-Browser — ohne Installation über den App Store. Funktionen: Hunde-Tagebuch mit Fotos und Wetter, digitale Gesundheitsakte, interaktive Karte mit Hundewiesen und Giftköder-Alarm, Community-Forum und Trainingspläne. Gegründet 2024 von René Degelmann, Ebersberg bei München. Erreichbar unter banyaro.app.</p>
</div>
</section>
<!-- Screenshots -->
<section>
<div class="section-label">Screenshots — zur redaktionellen Verwendung freigegeben</div>
<div class="download-grid">
<a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg">
<img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch">
<div class="card-label"><span>Tagebuch</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-2.jpg" download="banyaro-karte-giftkoederr.jpg">
<img class="thumb" src="/img/screenshots/screen-2.jpg" alt="Karte & Giftköder-Alarm">
<div class="card-label"><span>Karte & Alarm</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-3.jpg" download="banyaro-gesundheitsakte.jpg">
<img class="thumb" src="/img/screenshots/screen-3.jpg" alt="Gesundheitsakte">
<div class="card-label"><span>Gesundheitsakte</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-9.jpg" download="banyaro-forum.jpg">
<img class="thumb" src="/img/screenshots/screen-9.jpg" alt="Forum & Community">
<div class="card-label"><span>Forum</span> <span class="dl-icon"></span></div>
</a>
</div>
</section>
<!-- Logo -->
<section>
<div class="section-label">Logo</div>
<div class="download-grid">
<a class="download-card logo-card" href="/icons/icon-512.png" download="banyaro-logo-512.png">
<img class="thumb" src="/icons/icon-512.png" alt="Ban Yaro Logo">
<div class="card-label"><span>Logo PNG 512px</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card logo-card" href="/icons/icon-192.png" download="banyaro-logo-192.png">
<img class="thumb" src="/icons/icon-192.png" alt="Ban Yaro Logo 192">
<div class="card-label"><span>Logo PNG 192px</span> <span class="dl-icon"></span></div>
</a>
</div>
</section>
<!-- Gründer -->
<section>
<div class="section-label">Gründer</div>
<div class="founder-card">
<img src="/icons/founder.jpg" alt="René Degelmann mit Ban Yaro">
<div>
<h3>René Degelmann</h3>
<div class="role">Gründer & Entwickler, Ban Yaro</div>
<p>René Degelmann ist Softwareentwickler aus Ebersberg bei München. Ban Yaro hat er für seinen eigenen Hund gebaut — und dann gemerkt, dass tausende andere Hundehalter das gleiche brauchen. Die App entstand ohne Investoren, ohne App-Store-Zwang und ohne Werbung.</p>
</div>
</div>
<p style="margin-top:.75rem; font-size:.8rem; color:var(--muted);">Foto zur redaktionellen Verwendung freigegeben — <a href="/icons/founder.jpg" download="rene-degelmann-ban-yaro.jpg" style="color:var(--primary)">herunterladen ↓</a></p>
</section>
<!-- Eckdaten -->
<section>
<div class="section-label">Eckdaten</div>
<div class="facts-grid">
<div class="fact"><div class="fact-label">Gegründet</div><div class="fact-value">2026</div></div>
<div class="fact"><div class="fact-label">Sitz</div><div class="fact-value">Ebersberg bei München</div></div>
<div class="fact"><div class="fact-label">Plattform</div><div class="fact-value">Progressive Web App</div></div>
<div class="fact"><div class="fact-label">Preis</div><div class="fact-value">Kostenlos</div></div>
<div class="fact"><div class="fact-label">Sprache</div><div class="fact-value">Deutsch</div></div>
<div class="fact"><div class="fact-label">Zielmarkt</div><div class="fact-value">D-A-CH</div></div>
</div>
</section>
<!-- Pressekontakt -->
<section>
<div class="section-label">Pressekontakt</div>
<div class="contact-box">
<p><strong>René Degelmann</strong></p>
<p>Ringstr. 26 · 85560 Ebersberg</p>
<p>Telefon: <a href="tel:+4917112096622">0171 1209622</a></p>
<p>E-Mail: <a href="mailto:partner@banyaro.app">partner@banyaro.app</a></p>
<p>Web: <a href="https://banyaro.app">banyaro.app</a></p>
</div>
</section>
</div>
<script>
function copyBoilerplate() {
const text = document.getElementById('boilerplate-text').innerText.replace('Kopieren', '').trim();
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.copy-btn');
btn.textContent = 'Kopiert ✓';
setTimeout(() => btn.textContent = 'Kopieren', 2000);
});
}
</script>
</body>
</html>

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v577';
const CACHE_VERSION = 'by-v651';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
@ -125,11 +125,34 @@ const _CACHEABLE_GET = [
/^\/api\/training\/progress/,
/^\/api\/wiki\/rassen/,
/^\/api\/dogs\/\d+\/diary\/stats/,
// Drei Welten — offline-fähig
/^\/api\/streak\/\d+/,
/^\/api\/forum\/threads/,
/^\/api\/weather$/,
/^\/api\/passport\/\d+$/,
];
function _isCacheableGet(pathname) {
return _CACHEABLE_GET.some(re => re.test(pathname));
}
// Cache-TTL: stabile Daten länger, dynamische kürzer
const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/];
const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
function _cacheTTL(pathname) {
return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT;
}
function _cacheStale(pathname) {
const ts = _cacheTs.get(pathname);
return !ts || (Date.now() - ts) > _cacheTTL(pathname);
}
function _cacheMark(pathname) {
_cacheTs.set(pathname, Date.now());
}
// ----------------------------------------------------------
// INSTALL — App Shell cachen
// ----------------------------------------------------------
@ -173,19 +196,27 @@ self.addEventListener('fetch', event => {
if (method === 'GET' && _isCacheableGet(url.pathname)) {
event.respondWith((async () => {
const cached = await caches.match(event.request);
const stale = _cacheStale(url.pathname);
const networkPromise = _fetchTimeout(event.request.clone(), 8000)
.then(resp => {
if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
if (resp.ok) {
_cacheMark(url.pathname);
caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
}
return resp;
})
.catch(() => null);
// Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen
if (cached) {
networkPromise.catch(() => {}); // fire and forget
// Cache noch frisch → sofort zurückgeben, Netz im Hintergrund
if (cached && !stale) {
networkPromise.catch(() => {});
return cached;
}
// Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback
const fresh = await networkPromise;
if (fresh) return fresh;
if (cached) return cached; // lieber veraltet als nichts
return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } });
})());

View file

@ -161,3 +161,251 @@ async def get_weather_summary() -> dict:
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
return {"max_temp_c": max_temp, "thunderstorm": thunderstorm}
# ---------------------------------------------------------------------------
# 7-Tage-Vorhersage
# ---------------------------------------------------------------------------
import asyncio # noqa: E402 — appended section
_forecast_cache: dict = {}
_FORECAST_TTL = 3600 # 1 Stunde
_WDAY_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
def _wind_dir(deg: float) -> str:
dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
idx = round(deg / 45) % 8
return dirs[idx]
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
bonus = min(uv_max * 3.0, 30.0)
asphalt = air_max + bonus
if asphalt <= 30:
warn = 'safe'
elif asphalt <= 40:
warn = 'warm'
elif asphalt <= 55:
warn = 'hot'
else:
warn = 'danger'
return round(asphalt, 1), warn
def _pollen_lvl(val: float | None) -> dict:
if val is None:
return {'level': 0, 'label': 'keine'}
if val < 5:
return {'level': 1, 'label': 'niedrig'}
if val < 25:
return {'level': 2, 'label': 'mittel'}
if val < 100:
return {'level': 3, 'label': 'hoch'}
return {'level': 4, 'label': 'sehr hoch'}
async def get_forecast(lat: float, lon: float) -> dict:
"""7-Tage-Wettervorhersage inkl. Pollen, Asphalttemperatur, Zecken. 1h TTL-Cache."""
key = (round(lat, 2), round(lon, 2))
now = time.time()
if key in _forecast_cache:
ts, cached = _forecast_cache[key]
if now - ts < _FORECAST_TTL:
return cached
forecast_url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}"
"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,"
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
"sunrise,sunset"
"&timezone=auto&forecast_days=7"
)
pollen_url = (
"https://air-quality-api.open-meteo.com/v1/air-quality"
f"?latitude={lat}&longitude={lon}"
"&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen"
"&timezone=auto&forecast_days=7"
)
async with httpx.AsyncClient(timeout=10.0) as client:
forecast_task = client.get(forecast_url)
pollen_task = client.get(pollen_url)
forecast_resp, pollen_resp = await asyncio.gather(
forecast_task, pollen_task, return_exceptions=True
)
# --- Forecast (required) ---
if isinstance(forecast_resp, Exception):
raise forecast_resp
forecast_resp.raise_for_status()
raw = forecast_resp.json()
daily = raw.get('daily', {})
timezone = raw.get('timezone', 'auto')
dates = daily.get('time', [])
temp_max = daily.get('temperature_2m_max', [])
temp_min = daily.get('temperature_2m_min', [])
feels_max = daily.get('apparent_temperature_max', [])
feels_min = daily.get('apparent_temperature_min', [])
precip_prob = daily.get('precipitation_probability_max', [])
precip_sum = daily.get('precipitation_sum', [])
wcodes = daily.get('weathercode', [])
wind_kmh = daily.get('windspeed_10m_max', [])
wind_deg = daily.get('winddirection_10m_dominant', [])
uv_index = daily.get('uv_index_max', [])
sunrises = daily.get('sunrise', [])
sunsets = daily.get('sunset', [])
# --- Pollen (optional) ---
pollen_daily: dict | None = None
if not isinstance(pollen_resp, Exception):
try:
pollen_resp.raise_for_status()
praw = pollen_resp.json()
hourly = praw.get('hourly', {})
htimes = hourly.get('time', [])
# aggregate hourly → daily max per type
pollen_types = {
'erle': hourly.get('alder_pollen', []),
'birke': hourly.get('birch_pollen', []),
'graeser': hourly.get('grass_pollen', []),
'beifuss': hourly.get('mugwort_pollen', []),
'ambrosia': hourly.get('ragweed_pollen', []),
}
# build date → max mapping per type
pollen_daily = {ptype: {} for ptype in pollen_types}
for i, ts_str in enumerate(htimes):
day_str = ts_str[:10] # 'YYYY-MM-DD'
for ptype, vals in pollen_types.items():
v = vals[i] if i < len(vals) else None
if v is not None:
prev = pollen_daily[ptype].get(day_str)
pollen_daily[ptype][day_str] = max(prev, v) if prev is not None else v
except Exception as e:
logger.warning(f"Pollen-Abruf fehlgeschlagen: {e}")
pollen_daily = None
# --- Assemble days ---
days = []
for i, date_str in enumerate(dates):
wcode = int(wcodes[i]) if i < len(wcodes) and wcodes[i] is not None else 0
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
t_max = temp_max[i] if i < len(temp_max) else None
t_min = temp_min[i] if i < len(temp_min) else None
f_max = feels_max[i] if i < len(feels_max) else None
f_min = feels_min[i] if i < len(feels_min) else None
pp = precip_prob[i] if i < len(precip_prob) else None
ps = precip_sum[i] if i < len(precip_sum) else None
wk = wind_kmh[i] if i < len(wind_kmh) else None
wd_deg = wind_deg[i] if i < len(wind_deg) else None
uv = uv_index[i] if i < len(uv_index) else None
# Sunrise / Sunset → HH:MM only (format: "2025-05-02T06:12")
sunrise_raw = sunrises[i] if i < len(sunrises) else None
sunset_raw = sunsets[i] if i < len(sunsets) else None
sunrise_hm = sunrise_raw[11:16] if sunrise_raw and len(sunrise_raw) >= 16 else sunrise_raw
sunset_hm = sunset_raw[11:16] if sunset_raw and len(sunset_raw) >= 16 else sunset_raw
# Weekday
try:
dt_obj = datetime.strptime(date_str, '%Y-%m-%d')
wday = _WDAY_DE[dt_obj.weekday()]
except Exception:
wday = ''
# Asphalt
asphalt_t, asphalt_w = _asphalt_temp(t_max or 0.0, uv or 0.0)
# Zecken
month = datetime.strptime(date_str, '%Y-%m-%d').month
zecken = None
if t_max is not None and t_max > 7.0 and 3 <= month <= 10:
zecken = 'hoch' if t_max > 20 else ('mittel' if t_max > 12 else 'niedrig')
# Pollen
if pollen_daily is not None:
pollen_out = {
pt: _pollen_lvl(pollen_daily[pt].get(date_str))
for pt in ('erle', 'birke', 'graeser', 'beifuss', 'ambrosia')
}
else:
pollen_out = None
days.append({
'date': date_str,
'wday': wday,
'weathercode': wcode,
'desc': desc,
'icon': icon,
'temp_max': t_max,
'temp_min': t_min,
'feels_max': f_max,
'feels_min': f_min,
'precip_prob': pp,
'precip_sum': ps,
'wind_kmh': wk,
'wind_dir': _wind_dir(wd_deg) if wd_deg is not None else None,
'wind_dir_deg': wd_deg,
'uv_index': uv,
'sunrise': sunrise_hm,
'sunset': sunset_hm,
'asphalt_temp': asphalt_t,
'asphalt_warn': asphalt_w,
'pollen': pollen_out,
'zecken': zecken,
'thunderstorm': wcode in {95, 96, 99},
'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0),
})
result = {'timezone': timezone, 'days': days}
_forecast_cache[key] = (now, result)
_log_forecast(round(lat, 1), round(lon, 1), days)
return result
def _log_forecast(lat_r: float, lon_r: float, days: list) -> None:
"""Speichert jeden Forecast-Tag in weather_log (INSERT OR IGNORE — kein Überschreiben)."""
if not days:
return
try:
import json
from database import db
with db() as conn:
for d in days:
pollen = d.get('pollen') or {}
conn.execute("""
INSERT OR IGNORE INTO weather_log
(date, lat_r, lon_r,
temp_max, temp_min, feels_max,
precip_prob, precip_sum,
wind_kmh, wind_dir, uv_index,
weathercode, weatherdesc,
sunrise, sunset,
asphalt_temp, asphalt_warn, zecken,
pollen_erle, pollen_birke, pollen_graeser,
pollen_beifuss, pollen_ambrosia,
forecast_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
d['date'], lat_r, lon_r,
d.get('temp_max'), d.get('temp_min'), d.get('feels_max'),
d.get('precip_prob'), d.get('precip_sum'),
d.get('wind_kmh'), d.get('wind_dir'), d.get('uv_index'),
d.get('weathercode'), d.get('desc'),
d.get('sunrise'), d.get('sunset'),
d.get('asphalt_temp'), d.get('asphalt_warn'), d.get('zecken'),
pollen.get('erle', {}).get('level'),
pollen.get('birke', {}).get('level'),
pollen.get('graeser', {}).get('level'),
pollen.get('beifuss', {}).get('level'),
pollen.get('ambrosia',{}).get('level'),
json.dumps(d, ensure_ascii=False),
))
except Exception as e:
logger.warning(f"weather_log insert fehlgeschlagen: {e}")