Release v1.3.0
This commit is contained in:
commit
15e2446ea7
68 changed files with 16373 additions and 465 deletions
39
Makefile
39
Makefile
|
|
@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \
|
||||||
--exclude='./.DS_Store'
|
--exclude='./.DS_Store'
|
||||||
|
|
||||||
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
|
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
|
||||||
logs logs-f shell db dev clean-cache check-ssh
|
logs logs-f shell db dev clean-cache check-ssh reports
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
|
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
|
||||||
|
|
@ -66,6 +66,7 @@ help:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
|
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
|
||||||
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
|
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
|
||||||
|
@echo " make reports Quartalsberichte generieren + committen"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
|
|
@ -127,6 +128,17 @@ staging: check-ssh
|
||||||
@echo " ✓ Staging fertig — https://staging.banyaro.app"
|
@echo " ✓ Staging fertig — https://staging.banyaro.app"
|
||||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10"
|
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo)
|
||||||
|
# Aufruf: make staging-db
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
staging-db: check-ssh
|
||||||
|
@echo "→ Produktions-DB nach Staging kopieren..."
|
||||||
|
@ssh -t $(DS_HOST) " \
|
||||||
|
sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \
|
||||||
|
sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \
|
||||||
|
echo '✓ DB kopiert'"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
|
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
|
||||||
# Beispiel: make release VERSION=1.1.0
|
# Beispiel: make release VERSION=1.1.0
|
||||||
|
|
@ -235,6 +247,31 @@ dev:
|
||||||
DB_PATH=./dev.db \
|
DB_PATH=./dev.db \
|
||||||
uvicorn main:app --reload --port 8001
|
uvicorn main:app --reload --port 8001
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# REPORTS — Quartalsberichte generieren und committen
|
||||||
|
# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
REPORT_DATE := $(shell date +%Y-%m-%d)
|
||||||
|
REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server
|
||||||
|
|
||||||
|
reports: check-ssh
|
||||||
|
@mkdir -p reports
|
||||||
|
@echo "→ Berichte generieren ($(REPORT_DATE))..."
|
||||||
|
@for section in $(REPORT_SECTIONS); do \
|
||||||
|
echo " → $$section..."; \
|
||||||
|
ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \
|
||||||
|
> reports/$(REPORT_DATE)-$$section.md; \
|
||||||
|
done
|
||||||
|
@echo "→ Berichte committen..."
|
||||||
|
@git add reports/
|
||||||
|
@git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht"
|
||||||
|
@echo ""
|
||||||
|
@echo " ✓ Alle Berichte erstellt und committed:"
|
||||||
|
@for section in $(REPORT_SECTIONS); do \
|
||||||
|
echo " reports/$(REPORT_DATE)-$$section.md"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# CACHE leeren — SW-Version erhöhen, dann restart
|
# CACHE leeren — SW-Version erhöhen, dann restart
|
||||||
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
|
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
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,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
|
|
||||||
|
|
||||||
def require_social_media(user=Depends(get_current_user)):
|
def require_social_media(user=Depends(get_current_user)):
|
||||||
"""Dependency: Social-Media-Manager oder Admin."""
|
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
|
||||||
if not (user.get("is_social_media") or user["rolle"] == "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.")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
||||||
return user
|
return user
|
||||||
|
|
|
||||||
63
backend/content_filter.py
Normal file
63
backend/content_filter.py
Normal 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.")
|
||||||
|
|
@ -701,7 +701,28 @@ def _migrate(conn_factory):
|
||||||
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
|
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("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS movie_votes (
|
CREATE TABLE IF NOT EXISTS movie_votes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -1051,6 +1072,19 @@ def _migrate(conn_factory):
|
||||||
pass
|
pass
|
||||||
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
|
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
|
# Wiki: Züchter-Verzeichnis
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS wiki_zuchter (
|
CREATE TABLE IF NOT EXISTS wiki_zuchter (
|
||||||
|
|
@ -1561,6 +1595,35 @@ def _migrate(conn_factory):
|
||||||
if 'from_account' not in existing_ol:
|
if 'from_account' not in existing_ol:
|
||||||
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
|
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
|
# 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()]
|
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
|
||||||
if 'js_exercise_id' not in existing_te:
|
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("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)")
|
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.")
|
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);
|
||||||
|
""")
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
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):
|
async def send_verify_email(to: str, name: str, token: str):
|
||||||
url = f"{APP_URL}/api/auth/verify/{token}"
|
url = f"{APP_URL}/api/auth/verify/{token}"
|
||||||
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
|
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
|
||||||
|
|
||||||
html = f"""\
|
body = f"""
|
||||||
<!DOCTYPE html>
|
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||||
<html lang="de">
|
<p style="margin:0 0 16px">
|
||||||
<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">
|
|
||||||
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0 0 32px">
|
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden gültig.</p>
|
||||||
<a href="{url}"
|
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||||
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">
|
|
||||||
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||||
</p>
|
</p>"""
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
plain = (
|
html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
|
||||||
f"Ban Yaro — E-Mail-Adresse bestätigen\n\n"
|
plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\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"
|
|
||||||
)
|
|
||||||
|
|
||||||
await send_email(to, subject, html, plain)
|
await send_email(to, subject, html, plain)
|
||||||
|
|
|
||||||
229
backend/main.py
229
backend/main.py
|
|
@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from brotli_asgi import BrotliMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
|
|
@ -46,6 +47,8 @@ logger = logging.getLogger(__name__)
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Ban Yaro startet...")
|
logger.info("Ban Yaro startet...")
|
||||||
init_db()
|
init_db()
|
||||||
|
from routes.movies import seed_movies
|
||||||
|
seed_movies()
|
||||||
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||||||
sched.start()
|
sched.start()
|
||||||
yield
|
yield
|
||||||
|
|
@ -67,11 +70,20 @@ app = FastAPI(
|
||||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
|
||||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
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
|
return response
|
||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
@ -123,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
app.add_middleware(MediaCacheMiddleware)
|
app.add_middleware(MediaCacheMiddleware)
|
||||||
|
app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
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.zucht_ki import router as zucht_ki_router
|
||||||
from routes.partner import router as partner_router
|
from routes.partner import router as partner_router
|
||||||
from routes.outreach import router as outreach_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(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -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(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
|
||||||
app.include_router(partner_router, prefix="/api", tags=["Partner"])
|
app.include_router(partner_router, prefix="/api", tags=["Partner"])
|
||||||
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
|
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(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
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(praise_router, prefix="/api/praise", tags=["Praise"])
|
||||||
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
||||||
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
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
|
# /partner — Influencer-Landingpage
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@app.get("/partner")
|
@app.get("/partner")
|
||||||
|
|
@ -1617,6 +1653,189 @@ async def partner_landing():
|
||||||
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
|
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 — {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 — banyaro.app</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(full_path: str):
|
async def spa_fallback(full_path: str):
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
|
||||||
Blocklist für Honeypot-Treffer.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -11,18 +11,23 @@ from datetime import datetime, timedelta
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
_buckets: dict[str, deque] = defaultdict(deque)
|
_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()
|
_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 = ""):
|
def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""):
|
||||||
"""
|
"""Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten."""
|
||||||
Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.
|
|
||||||
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
|
|
||||||
"""
|
|
||||||
ip = (request.client.host if request.client else "unknown")
|
ip = (request.client.host if request.client else "unknown")
|
||||||
|
|
||||||
# Blocklist prüfen
|
|
||||||
with _lock:
|
with _lock:
|
||||||
blocked_until = _blocklist.get(ip)
|
blocked_until = _blocklist.get(ip)
|
||||||
if blocked_until and datetime.utcnow() < blocked_until:
|
if blocked_until and datetime.utcnow() < blocked_until:
|
||||||
|
|
@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool:
|
||||||
elif until:
|
elif until:
|
||||||
del _blocklist[ip]
|
del _blocklist[ip]
|
||||||
return False
|
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()
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,6 @@ pywebpush==2.0.0
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
polyline==2.0.2
|
polyline==2.0.2
|
||||||
|
fpdf2==2.8.3
|
||||||
|
python-dateutil>=2.9
|
||||||
|
brotli-asgi==1.4.0
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,40 @@ class ThreadAdminPatch(BaseModel):
|
||||||
is_deleted: Optional[int] = None
|
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
|
# GET /api/admin/stats
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -322,11 +356,15 @@ async def list_users(
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.patch("/users/{uid}")
|
@router.patch("/users/{uid}")
|
||||||
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
|
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":
|
if data.rolle is not None and user["rolle"] != "admin":
|
||||||
raise HTTPException(403, "Rollenwechsel nur für Admins.")
|
raise HTTPException(403, "Rollenwechsel nur für Admins.")
|
||||||
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
|
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
|
||||||
raise HTTPException(400, "Ungültige Rolle.")
|
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:
|
with db() as conn:
|
||||||
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
|
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
|
|
|
||||||
547
backend/routes/adoption.py
Normal file
547
backend/routes/adoption.py
Normal 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]
|
||||||
|
|
@ -15,7 +15,7 @@ from auth import (
|
||||||
get_current_user
|
get_current_user
|
||||||
)
|
)
|
||||||
from username_blocklist import is_username_blocked
|
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()
|
router = APIRouter()
|
||||||
COOKIE_NAME = "by_token"
|
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):
|
def _send_verification_email(email: str, name: str, token: str):
|
||||||
if not _SMTP_READY:
|
if not _SMTP_READY:
|
||||||
return
|
return
|
||||||
|
import html as _html
|
||||||
from routes.outreach import _send_smtp
|
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"
|
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
|
||||||
body = (
|
_ename = _html.escape(name)
|
||||||
f"Hallo {name},\n\n"
|
body_html = f"""
|
||||||
"willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
|
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
|
||||||
f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
|
<p style="margin:0 0 16px">
|
||||||
"Der Link ist 7 Tage gültig.\n\n"
|
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
|
||||||
"Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
|
</p>
|
||||||
"Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
|
<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:
|
try:
|
||||||
_send_smtp(email, subject, body, "support")
|
_send_smtp(email, subject, plain, "support", html=html)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Nicht blockieren wenn SMTP fehlschlägt
|
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=?",
|
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
||||||
(referrer['id'], new_user_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)
|
_send_verification_email(data.email, name, verify_token)
|
||||||
return {"token": token, "name": name, "email_verified": 0}
|
return {"pending_verification": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(data: LoginRequest, response: Response, request: Request):
|
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=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:
|
with db() as conn:
|
||||||
user = conn.execute(
|
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,)
|
(data.email,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not user or not verify_password(data.password, user["pw_hash"]):
|
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.")
|
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"])
|
token = create_token(user["id"], user["rolle"])
|
||||||
_set_cookie(response, token)
|
_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)
|
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
@router.post("/resend-verification")
|
@router.post("/resend-verification")
|
||||||
async def resend_verification(request: Request, user=Depends(get_current_user)):
|
async def resend_verification(data: ResendVerificationRequest, request: Request):
|
||||||
rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
|
rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}")
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
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()
|
).fetchone()
|
||||||
if not row:
|
if not row or row["email_verified"]:
|
||||||
raise HTTPException(404)
|
return {"ok": True}
|
||||||
if row["email_verified"]:
|
|
||||||
return {"ok": True, "already_verified": True}
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
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}
|
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=?",
|
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
|
||||||
(token, expires, user["id"])
|
(token, expires, user["id"])
|
||||||
)
|
)
|
||||||
|
import html as _html
|
||||||
app_url = os.getenv("APP_URL", "https://banyaro.app")
|
app_url = os.getenv("APP_URL", "https://banyaro.app")
|
||||||
|
url = f"{app_url}/#reset-password?token={token}"
|
||||||
subject = "Ban Yaro — Passwort zurücksetzen"
|
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 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:
|
try:
|
||||||
_send_smtp(data.email, subject, body, "support")
|
_send_smtp(data.email, subject, plain, "support", html=html)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Optional
|
||||||
|
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, require_premium
|
from auth import get_current_user, require_premium
|
||||||
from mailer import send_email
|
from mailer import send_email, email_html
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -131,21 +131,21 @@ async def breeder_apply(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Admin benachrichtigen
|
# Admin benachrichtigen
|
||||||
admin_html = f"""
|
admin_body = f"""
|
||||||
<h2>Neuer Züchter-Antrag</h2>
|
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
|
||||||
<p><b>Von:</b> {user['name']} ({user['email']})</p>
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||||
<p><b>Zwingername:</b> {zwingername}</p>
|
<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>
|
||||||
<p><b>Rasse:</b> {rasse_text}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
|
||||||
<p><b>Verein:</b> {verein}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
|
||||||
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
|
||||||
<p><b>Stadt:</b> {stadt}</p>
|
<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>
|
||||||
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
|
||||||
"""
|
</table>"""
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
ADMIN_EMAIL,
|
ADMIN_EMAIL,
|
||||||
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
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}",
|
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bestätigungs-Mail
|
# Bestätigungs-Mail
|
||||||
html = f"""
|
approve_body = f"""
|
||||||
<h2>Willkommen als Züchter bei Banyaro!</h2>
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||||
<p>Hallo {user['name']},</p>
|
<p style="margin:0 0 16px">
|
||||||
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
|
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
|
||||||
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
|
Ab sofort hast du Zugang zu allen Züchter-Features.
|
||||||
<p><a href="{APP_URL}">Zur App</a></p>
|
</p>"""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
user["email"],
|
user["email"],
|
||||||
"Willkommen als Züchter bei Banyaro!",
|
"Willkommen als Züchter bei Ban Yaro!",
|
||||||
html,
|
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
|
||||||
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ablehnungs-Mail
|
# Ablehnungs-Mail
|
||||||
html = f"""
|
import html as _h
|
||||||
<h2>Dein Züchter-Antrag bei Banyaro</h2>
|
reject_body = f"""
|
||||||
<p>Hallo {user['name']},</p>
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||||
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
|
<p style="margin:0 0 16px">
|
||||||
<p><b>Grund:</b> {body.grund}</p>
|
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
|
||||||
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
|
</p>
|
||||||
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></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:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
user["email"],
|
user["email"],
|
||||||
"Dein Züchter-Antrag bei Banyaro",
|
"Dein Züchter-Antrag bei Ban Yaro",
|
||||||
html,
|
email_html(reject_body),
|
||||||
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)):
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
d["is_guest"] = True
|
d["is_guest"] = True
|
||||||
result.append(d)
|
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
|
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"]]
|
values = list(fields.values()) + [dog_id, user["id"]]
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
updated = conn.execute(
|
||||||
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
|
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(
|
dog = conn.execute(
|
||||||
"SELECT * FROM dogs WHERE id=?", (dog_id,)
|
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(dog)
|
return dict(dog)
|
||||||
|
|
||||||
|
|
@ -398,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE 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,)
|
(dog_id, user["id"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
396
backend/routes/expenses.py
Normal file
396
backend/routes/expenses.py
Normal 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
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional
|
||||||
from timeutils import safe_client_time
|
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 routes.push import send_push_to_user
|
||||||
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
|
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
|
# 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)
|
@router.post("/threads", status_code=201)
|
||||||
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
||||||
if not user.get("email_verified"):
|
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:
|
if data.kategorie not in KATEGORIEN:
|
||||||
raise HTTPException(400, "Ungültige Kategorie.")
|
raise HTTPException(400, "Ungültige Kategorie.")
|
||||||
with db() as conn:
|
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)
|
ct = safe_client_time(data.client_time)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
|
"""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 = dict(row)
|
||||||
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
|
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
|
||||||
t['user_liked'] = False
|
t['user_liked'] = False
|
||||||
|
record_post(user["id"], data.text.strip())
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
||||||
if thread['is_deleted']:
|
if thread['is_deleted']:
|
||||||
raise HTTPException(404, "Thread nicht gefunden.")
|
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)
|
ct = safe_client_time(data.client_time)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
"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 = dict(row)
|
||||||
pd['foto_urls'] = []
|
pd['foto_urls'] = []
|
||||||
pd['user_liked'] = False
|
pd['user_liked'] = False
|
||||||
|
record_post(user["id"], data.text.strip())
|
||||||
|
|
||||||
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
||||||
if owner_id and owner_id != user['id']:
|
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
|
# GET /api/forum/members/map
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.get("/members/map")
|
@router.get("/members/map")
|
||||||
async def members_map():
|
async def members_map(user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
|
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
|
||||||
|
|
|
||||||
138
backend/routes/health_docs.py
Normal file
138
backend/routes/health_docs.py
Normal 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
327
backend/routes/jobs.py
Normal 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
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""BAN YARO — KI Routes"""
|
"""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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import ki as ki_module
|
import ki as ki_module
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from ratelimit import check as rl_check
|
from ratelimit import check as rl_check
|
||||||
|
from database import db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
||||||
raise HTTPException(503, str(e))
|
raise HTTPException(503, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post("/litters/{litter_id}/welfare-confirm")
|
@router.post("/litters/{litter_id}/welfare-confirm")
|
||||||
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
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
|
import os, logging as _log
|
||||||
_logger = _log.getLogger(__name__)
|
_logger = _log.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -265,19 +265,21 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
||||||
eltern = conn.execute(
|
eltern = conn.execute(
|
||||||
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
html = f"""
|
import html as _html
|
||||||
<h2>Tierschutz-Hinweis bestätigt</h2>
|
welfare_body = f"""
|
||||||
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
|
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
|
||||||
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||||
<p>Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}</p>
|
<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>
|
||||||
<p>Wurf-ID: {litter_id}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
|
||||||
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
|
<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:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
admin_email,
|
admin_email,
|
||||||
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
|
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.",
|
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""BAN YARO — Moderations-Panel Backend"""
|
"""BAN YARO — Moderations-Panel Backend"""
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
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)):
|
async def mod_reports(user=Depends(require_moderator)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
|
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved,
|
||||||
u.name AS melder_name,
|
r.created_at, r.resolved_at,
|
||||||
|
u.name AS melder_name,
|
||||||
|
m.name AS resolved_by_name,
|
||||||
CASE r.target_type
|
CASE r.target_type
|
||||||
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
|
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)
|
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
|
||||||
END AS content_preview
|
END AS content_preview
|
||||||
FROM forum_reports r
|
FROM forum_reports r
|
||||||
LEFT JOIN users u ON u.id=r.user_id
|
LEFT JOIN users u ON u.id=r.user_id
|
||||||
WHERE r.resolved=0
|
LEFT JOIN users m ON m.id=r.resolved_by
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.resolved ASC, r.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 200
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
return [dict(r) for r in rows]
|
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.")
|
raise HTTPException(404, "Meldung nicht gefunden.")
|
||||||
new_state = 0 if r["resolved"] else 1
|
new_state = 0 if r["resolved"] else 1
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE forum_reports SET resolved=? WHERE id=?",
|
"""UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=?
|
||||||
(new_state, rid)
|
WHERE id=?""",
|
||||||
|
(new_state,
|
||||||
|
user["id"] if new_state else None,
|
||||||
|
datetime.utcnow().isoformat() if new_state else None,
|
||||||
|
rid)
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
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)):
|
async def mod_fotos(user=Depends(require_moderator)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
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,
|
COALESCE(s.rights_confirmed, 0) AS rights_confirmed,
|
||||||
u.name AS user_name,
|
u.name AS user_name,
|
||||||
r.name AS rasse_name, r.slug AS rasse_slug,
|
m.name AS reviewed_by_name,
|
||||||
|
r.name AS rasse_name, r.slug AS rasse_slug,
|
||||||
r.foto_url AS aktuell_foto
|
r.foto_url AS aktuell_foto
|
||||||
FROM wiki_foto_submissions s
|
FROM wiki_foto_submissions s
|
||||||
LEFT JOIN users u ON u.id = s.user_id
|
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
|
LEFT JOIN wiki_rassen r ON r.id = s.rasse_id
|
||||||
WHERE s.status = 'pending'
|
ORDER BY s.status ASC, s.created_at ASC
|
||||||
ORDER BY s.created_at ASC
|
LIMIT 200
|
||||||
LIMIT 50
|
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
return [dict(r) for r in rows]
|
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,
|
SELECT e.id, e.osm_id, e.poi_name, e.field,
|
||||||
e.old_value, e.new_value, e.status,
|
e.old_value, e.new_value, e.status,
|
||||||
e.created_at, e.resolved_at,
|
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
|
FROM osm_poi_edits e
|
||||||
JOIN users u ON u.id = e.user_id
|
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
|
ORDER BY e.status ASC, e.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 200
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
return [dict(r) for r in rows]
|
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.")
|
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
|
||||||
|
|
||||||
if action == "approve":
|
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(
|
conn.execute(
|
||||||
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
|
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
|
||||||
(edit["new_value"], edit["osm_id"])
|
(edit["new_value"], edit["osm_id"])
|
||||||
|
|
|
||||||
|
|
@ -1,140 +1,380 @@
|
||||||
"""BAN YARO — Hunde-Filme Routes"""
|
"""BAN YARO — Hunde-Filme Routes"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional, require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hardcoded Film-Daten
|
# Seed-Daten — werden beim ersten Start in die DB geschrieben
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
FILME = [
|
_SEED_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},
|
# ── Originalbestand ──────────────────────────────────────────────
|
||||||
{"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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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},
|
{"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 = [
|
_SEED_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": "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-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "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). Wurde zur sowjetischen Weltraumpionierin.", "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": "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 (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
|
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). 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": "⛪"},
|
{"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 (1934–1947). 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
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
class FilmVoteRequest(BaseModel):
|
class FilmVoteRequest(BaseModel):
|
||||||
bewertung: int # 1–5
|
bewertung: int # 1–5
|
||||||
|
|
||||||
|
|
||||||
class HundDesMonatsVoteRequest(BaseModel):
|
class HundDesMonatsVoteRequest(BaseModel):
|
||||||
dog_id: int
|
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")
|
@router.get("/filme")
|
||||||
async def get_filme(user=Depends(get_current_user_optional)):
|
async def get_filme(
|
||||||
user_ratings = {}
|
sort: str = Query("default"),
|
||||||
community_avgs = {}
|
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:
|
with db() as conn:
|
||||||
if user:
|
rows = conn.execute(f"""
|
||||||
rows = conn.execute(
|
SELECT m.*,
|
||||||
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
|
COALESCE(AVG(v.bewertung), 0) AS community_avg,
|
||||||
(user["id"],),
|
COUNT(v.id) AS bewertung_cnt,
|
||||||
).fetchall()
|
uv.bewertung AS user_rating
|
||||||
user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
|
FROM movies m
|
||||||
|
LEFT JOIN movie_votes v ON v.film_id = m.id
|
||||||
avg_rows = conn.execute(
|
LEFT JOIN movie_votes uv ON uv.film_id = m.id
|
||||||
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
|
AND uv.user_id = ?
|
||||||
).fetchall()
|
{where}
|
||||||
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
|
GROUP BY m.id
|
||||||
|
ORDER BY {order}
|
||||||
|
""", [user["id"] if user else None] + params).fetchall()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for film in FILME:
|
for r in rows:
|
||||||
f = dict(film)
|
d = dict(r)
|
||||||
f["user_rating"] = user_ratings.get(film["id"])
|
d["stirbt_der_hund"] = bool(d["stirbt_der_hund"])
|
||||||
if film["id"] in community_avgs:
|
d["bewertung_avg"] = round(d["community_avg"] or 0, 1)
|
||||||
f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
|
result.append(d)
|
||||||
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
|
|
||||||
else:
|
|
||||||
f["bewertung_cnt"] = 0
|
|
||||||
result.append(f)
|
|
||||||
|
|
||||||
return result
|
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")
|
@router.post("/filme/{film_id}/vote")
|
||||||
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
|
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:
|
if data.bewertung < 1 or data.bewertung > 5:
|
||||||
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
|
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone():
|
||||||
"""INSERT INTO movie_votes (user_id, film_id, bewertung)
|
raise HTTPException(404, "Film nicht gefunden.")
|
||||||
VALUES (?, ?, ?)
|
conn.execute("""
|
||||||
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
|
INSERT INTO movie_votes (user_id, film_id, bewertung)
|
||||||
(user["id"], film_id, data.bewertung),
|
VALUES (?, ?, ?)
|
||||||
)
|
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung
|
||||||
|
""", (user["id"], film_id, data.bewertung))
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
|
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
|
||||||
(film_id,),
|
(film_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"film_id": film_id,
|
"film_id": film_id,
|
||||||
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
|
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
|
||||||
"bewertung_cnt": row["cnt"],
|
"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")
|
@router.get("/hund-des-monats")
|
||||||
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
||||||
monat = datetime.now().strftime("%Y-%m")
|
monat = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute("""
|
||||||
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
|
SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
|
||||||
COUNT(v.id) as stimmen
|
COUNT(v.id) as stimmen
|
||||||
FROM hund_des_monats_votes v
|
FROM hund_des_monats_votes v
|
||||||
JOIN dogs d ON d.id = v.dog_id
|
JOIN dogs d ON d.id = v.dog_id
|
||||||
JOIN users u ON u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE v.monat = ?
|
WHERE v.monat = ?
|
||||||
GROUP BY v.dog_id
|
GROUP BY v.dog_id
|
||||||
ORDER BY stimmen DESC
|
ORDER BY stimmen DESC
|
||||||
LIMIT 10""",
|
LIMIT 10
|
||||||
(monat,),
|
""", (monat,)).fetchall()
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
user_vote = None
|
user_vote = None
|
||||||
if user:
|
if user:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|
@ -143,43 +383,55 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
user_vote = row["dog_id"]
|
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],
|
@router.get("/hund-des-monats/kandidaten")
|
||||||
"user_vote": user_vote,
|
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")
|
@router.post("/hund-des-monats/vote")
|
||||||
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
||||||
monat = datetime.now().strftime("%Y-%m")
|
monat = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
with db() as conn:
|
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:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
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.")
|
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
|
||||||
|
conn.execute("""
|
||||||
conn.execute(
|
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
||||||
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
VALUES (?, ?, ?)
|
||||||
VALUES (?, ?, ?)
|
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id
|
||||||
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
|
""", (user["id"], data.dog_id, monat))
|
||||||
(user["id"], data.dog_id, monat),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Aktuelle Stimmenanzahl für den gewählten Hund
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
|
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
|
||||||
(data.dog_id, monat),
|
(data.dog_id, monat),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}
|
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr, formatdate
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
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)
|
_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"]
|
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||||
msg = MIMEMultipart("alternative")
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["From"] = formataddr((acc["name"], acc["from"]))
|
msg["From"] = formataddr((acc["name"], acc["from"]))
|
||||||
msg["To"] = to
|
msg["To"] = to
|
||||||
msg["Reply-To"] = acc["from"]
|
msg["Reply-To"] = acc["from"]
|
||||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
if html:
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
return msg
|
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"]
|
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||||
if not acc["user"] or not acc["pass"]:
|
if not acc["user"] or not acc["pass"]:
|
||||||
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
|
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()
|
msg_bytes = msg.as_bytes()
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
|
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
|
# 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")
|
@router.post("/send")
|
||||||
def send_mail(data: SendRequest, user=Depends(require_admin)):
|
def send_mail(data: SendRequest, user=Depends(require_admin)):
|
||||||
if not data.to:
|
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():
|
if not data.subject.strip() or not data.body.strip():
|
||||||
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
|
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 = [], []
|
sent, failed = [], []
|
||||||
for addr in data.to:
|
for addr in data.to:
|
||||||
addr = addr.strip()
|
addr = addr.strip()
|
||||||
if not addr:
|
if not addr:
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
sent.append(addr)
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
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):
|
def send_support_mail(to: str, subject: str, body: str):
|
||||||
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
|
"""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)):
|
def outreach_log_endpoint(user=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
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
|
ol.from_account, u.name AS sent_by_name
|
||||||
FROM outreach_log ol
|
FROM outreach_log ol
|
||||||
JOIN users u ON u.id = ol.sent_by
|
JOIN users u ON u.id = ol.sent_by
|
||||||
|
|
|
||||||
377
backend/routes/passport.py
Normal file
377
backend/routes/passport.py
Normal 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
364
backend/routes/playdate.py
Normal 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
138
backend/routes/recalls.py
Normal 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
114
backend/routes/streak.py
Normal 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}
|
||||||
|
|
@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
|
||||||
return result
|
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("")
|
@router.get("")
|
||||||
async def list_tieraerzte(user=Depends(get_current_user)):
|
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:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
|
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchall()
|
).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")
|
@router.get("/osm-nearby")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ BAN YARO — Wetter-API
|
||||||
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, HTTPException
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
import weather as weather_module
|
import weather as weather_module
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -18,3 +19,15 @@ async def get_weather(
|
||||||
return await weather_module.get_weather_for_location(lat, lon)
|
return await weather_module.get_weather_for_location(lat, lon)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(503, f'Wetter nicht verfügbar: {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}')
|
||||||
|
|
|
||||||
|
|
@ -317,19 +317,24 @@ async def submit_foto(
|
||||||
if not rights_confirmed:
|
if not rights_confirmed:
|
||||||
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
|
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
|
||||||
|
|
||||||
# Dateiformat prüfen
|
_IMAGE_MAGIC = [
|
||||||
ct = file.content_type or ""
|
b"\xff\xd8\xff", # JPEG
|
||||||
if not ct.startswith("image/"):
|
b"\x89PNG\r\n\x1a\n", # PNG
|
||||||
raise HTTPException(400, "Nur Bilddateien erlaubt.")
|
b"RIFF", # WebP (RIFF....WEBP)
|
||||||
|
b"GIF87a", b"GIF89a", # GIF
|
||||||
|
]
|
||||||
|
|
||||||
os.makedirs(SUBMIT_DIR, exist_ok=True)
|
os.makedirs(SUBMIT_DIR, exist_ok=True)
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
filename = f"{slug}_{user['id']}_{ts}.jpg"
|
|
||||||
path = os.path.join(SUBMIT_DIR, filename)
|
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if len(content) > 8 * 1024 * 1024:
|
if len(content) > 8 * 1024 * 1024:
|
||||||
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
|
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:
|
with open(path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
@ -694,11 +699,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)):
|
||||||
raise HTTPException(403, "Nur Moderatoren.")
|
raise HTTPException(403, "Nur Moderatoren.")
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
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
|
FROM wiki_zuchter z
|
||||||
LEFT JOIN users u ON u.id = z.user_id
|
LEFT JOIN users u ON u.id = z.user_id
|
||||||
WHERE z.verified=0
|
LEFT JOIN users m ON m.id = z.verified_by
|
||||||
ORDER BY z.created_at ASC""",
|
ORDER BY z.verified ASC, z.created_at ASC
|
||||||
|
LIMIT 200""",
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
@ -716,8 +722,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Züchter nicht gefunden.")
|
raise HTTPException(404, "Züchter nicht gefunden.")
|
||||||
|
from datetime import datetime
|
||||||
conn.execute(
|
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(
|
result = conn.execute(
|
||||||
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
|
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,22 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=1800,
|
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)
|
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_ki_health_report,
|
_job_ki_health_report,
|
||||||
|
|
@ -108,8 +124,40 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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()
|
_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():
|
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)
|
# JOB: Status-Report per Mail (täglich 06:00 Uhr)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def _job_status_report():
|
async def _job_status_report():
|
||||||
|
|
@ -669,6 +826,7 @@ async def _job_status_report():
|
||||||
|
|
||||||
# Community
|
# Community
|
||||||
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
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["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
|
||||||
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").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]
|
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:
|
except Exception:
|
||||||
metrics["lost_active"] = 0
|
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
|
# Wiki-Interesse
|
||||||
try:
|
try:
|
||||||
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
|
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)",
|
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
|
||||||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||||||
"ki_health_report": "KI-Gesundheitsberichte",
|
"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_html = ""
|
||||||
job_rows_txt = ""
|
job_rows_txt = ""
|
||||||
|
|
@ -727,6 +910,9 @@ async def _job_status_report():
|
||||||
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Items -->
|
||||||
|
{_action_items_html(metrics)}
|
||||||
|
|
||||||
<!-- Scheduler-Status -->
|
<!-- Scheduler-Status -->
|
||||||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
<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>
|
<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="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">
|
<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 [
|
{"".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"]),
|
("Hunde",metrics["dogs"]),
|
||||||
("Tagebuch-Einträge",metrics["diary_entries"]),
|
("Tagebuch-Einträge",metrics["diary_entries"]),
|
||||||
("Aktive Giftköder",metrics["poison_active"]),
|
("Aktive Giftköder",metrics["poison_active"]),
|
||||||
("Vermisste Hunde",metrics["lost_active"]),
|
("Vermisste Hunde",metrics["lost_active"]),
|
||||||
("'So einen hab ich'",metrics["interesse_hat"]),
|
("'So einen hab ich'",metrics["interesse_hat"]),
|
||||||
("'Interessiert mich'",metrics["interesse_will"]),
|
("'Interessiert mich'",metrics["interesse_will"]),
|
||||||
("Züchter (pending)",metrics["zuchter_pending"]),
|
|
||||||
])}
|
])}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -761,19 +947,28 @@ async def _job_status_report():
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</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}
|
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 ===
|
=== Scheduler-Jobs ===
|
||||||
{job_rows_txt}
|
{job_rows_txt}
|
||||||
=== Community ===
|
=== Community ===
|
||||||
Nutzer: {metrics['users']}
|
Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute)
|
||||||
Hunde: {metrics['dogs']}
|
Hunde: {metrics['dogs']}
|
||||||
Tagebuch-Einträge: {metrics['diary_entries']}
|
Tagebuch-Einträge: {metrics['diary_entries']}
|
||||||
Aktive Giftköder: {metrics['poison_active']}
|
Aktive Giftköder: {metrics['poison_active']}
|
||||||
Vermisste Hunde: {metrics['lost_active']}
|
Vermisste Hunde: {metrics['lost_active']}
|
||||||
'So einen hab ich': {metrics['interesse_hat']}
|
'So einen hab ich': {metrics['interesse_hat']}
|
||||||
'Interessiert mich': {metrics['interesse_will']}
|
'Interessiert mich': {metrics['interesse_will']}
|
||||||
Züchter (pending): {metrics['zuchter_pending']}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -783,6 +978,133 @@ Züchter (pending): {metrics['zuchter_pending']}
|
||||||
logger.error(f"Status-Report: Mail-Fehler: {e}")
|
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):
|
def _compute_milestone(today: date, bday: date, dog_name: str):
|
||||||
"""
|
"""
|
||||||
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
|
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 titel, text
|
||||||
|
|
||||||
return None
|
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))
|
||||||
|
|
|
||||||
725
backend/scripts/generate_reports.py
Normal file
725
backend/scripts/generate_reports.py
Normal 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 1–3)",
|
||||||
|
"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
|
|
@ -105,9 +105,9 @@
|
||||||
--transition-slow: 320ms ease;
|
--transition-slow: 320ms ease;
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
--nav-bottom-height: 64px;
|
--nav-bottom-height: 78px; /* Welten-Zurück-FAB: 54px + 20px bottom + 4px Abstand */
|
||||||
--nav-sidebar-width: 240px;
|
--nav-sidebar-width: 240px;
|
||||||
--header-height: 56px;
|
--header-height: 0px; /* Header entfernt — Welten-Navigation übernimmt */
|
||||||
|
|
||||||
/* Safe Areas (iPhone Notch/Home Indicator) */
|
/* Safe Areas (iPhone Notch/Home Indicator) */
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
|
min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding-top: var(--safe-top);
|
||||||
padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px);
|
padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
backend/static/icons/founder.jpg
Normal file
BIN
backend/static/icons/founder.jpg
Normal file
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 |
|
|
@ -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">
|
<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/">
|
<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 -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
|
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
|
||||||
|
|
@ -88,9 +93,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=545">
|
<link rel="stylesheet" href="/css/design-system.css?v=651">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=545">
|
<link rel="stylesheet" href="/css/layout.css?v=651">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=545">
|
<link rel="stylesheet" href="/css/components.css?v=651">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -158,6 +163,9 @@
|
||||||
<div class="sidebar-item" data-page="notes">
|
<div class="sidebar-item" data-page="notes">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
|
||||||
</div>
|
</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>
|
<span class="sidebar-section-label">Entdecken</span>
|
||||||
<div class="sidebar-item" data-page="map">
|
<div class="sidebar-item" data-page="map">
|
||||||
|
|
@ -169,12 +177,24 @@
|
||||||
<div class="sidebar-item" data-page="events">
|
<div class="sidebar-item" data-page="events">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Events
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Events
|
||||||
</div>
|
</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>
|
<span class="sidebar-section-label">Soziales</span>
|
||||||
<div class="sidebar-item" data-page="friends">
|
<div class="sidebar-item" data-page="friends">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
|
<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>
|
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
|
||||||
</div>
|
</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">
|
<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
|
<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>
|
<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
|
<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>
|
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
|
||||||
</div>
|
</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">
|
<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
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,6 +475,30 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||||
|
|
@ -480,6 +527,34 @@
|
||||||
|
|
||||||
</div><!-- #app -->
|
</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) -->
|
<!-- TOAST CONTAINER (außerhalb der App, immer sichtbar) -->
|
||||||
<div class="toast-container" id="toast-container" role="alert" aria-live="polite"></div>
|
<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/api.js?v=94"></script>
|
||||||
<script src="/js/ui.js?v=94"></script>
|
<script src="/js/ui.js?v=94"></script>
|
||||||
<script src="/js/app.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 -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,69 +6,84 @@
|
||||||
|
|
||||||
const API = (() => {
|
const API = (() => {
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const _inflight = new Map();
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Interner HTTP-Kern
|
// Interner HTTP-Kern
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _request(method, path, body = null, options = {}) {
|
async function _doRequest(method, path, body, attempt) {
|
||||||
const config = {
|
const config = {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
|
credentials: 'include',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body && !(body instanceof FormData)) {
|
if (body && !(body instanceof FormData)) {
|
||||||
config.body = JSON.stringify(body);
|
config.body = JSON.stringify(body);
|
||||||
} else if (body instanceof FormData) {
|
} else if (body instanceof FormData) {
|
||||||
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
|
delete config.headers['Content-Type'];
|
||||||
config.body = body;
|
config.body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
|
|
||||||
const token = localStorage.getItem('by_token');
|
const token = localStorage.getItem('by_token');
|
||||||
if (token) {
|
if (token) config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`/api${path}`, config);
|
response = await fetch(`/api${path}`, config);
|
||||||
} catch (err) {
|
} catch {
|
||||||
const offlineMsg = 'Kein Internet — du bist offline.';
|
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
|
||||||
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
|
if (method === 'GET' && attempt < 2) {
|
||||||
throw new APIError(offlineMsg, 0, 'network');
|
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;
|
if (response.status === 204) return null;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try { data = await response.json(); } catch { data = null; }
|
||||||
data = await response.json();
|
|
||||||
} catch {
|
|
||||||
data = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
||||||
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
|
const isSwOffline = response.status === 503 && message.startsWith('Offline');
|
||||||
const isOffline = response.status === 503 && message.startsWith('Offline');
|
|
||||||
if (isOffline && window.UI && UI.toast) {
|
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
|
||||||
UI.toast.warning('Kein Internet — du bist offline.', 4000);
|
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 (data?._queued) {
|
||||||
if (typeof UI !== 'undefined' && UI.toast) {
|
if (typeof UI !== 'undefined' && UI.toast)
|
||||||
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
|
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Öffentliche HTTP-Methoden
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -195,6 +210,17 @@ const API = (() => {
|
||||||
create(data) { return post('/tieraerzte', data); },
|
create(data) { return post('/tieraerzte', data); },
|
||||||
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
||||||
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
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
|
// WETTER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const weather = {
|
const weather = {
|
||||||
alerts(lat, lon) { return get(`/weather/alerts?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}`); },
|
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
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
get, post, put, patch, del, upload,
|
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,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||||
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
@ -70,6 +70,12 @@ const App = (() => {
|
||||||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||||
gruender: { title: '100 Gründer', 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 },
|
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' },
|
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 },
|
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 = {}) {
|
function navigate(pageId, pushHistory = true, params = {}) {
|
||||||
if (!pages[pageId]) return;
|
if (!pages[pageId]) return;
|
||||||
|
if (window.Worlds?._visible) window.Worlds.hide();
|
||||||
|
|
||||||
// Aktive Seite ausblenden
|
// Aktive Seite ausblenden
|
||||||
document.querySelector('.page.active')?.classList.remove('active');
|
document.querySelector('.page.active')?.classList.remove('active');
|
||||||
|
|
@ -564,7 +572,7 @@ const App = (() => {
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
|
|
||||||
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
|
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.');
|
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
|
@ -846,6 +854,9 @@ const App = (() => {
|
||||||
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
|
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
|
||||||
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
|
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
|
||||||
navigate(state.user ? startPage : 'welcome', false, hashParams);
|
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) {
|
async function _handleInvite(token) {
|
||||||
|
|
@ -919,6 +930,8 @@ const App = (() => {
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
window.App = App; // Worlds kann App.navigate() aufrufen
|
||||||
|
|
||||||
// App starten
|
// App starten
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
App.init();
|
App.init();
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ window.Page_admin = (() => {
|
||||||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||||
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
||||||
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
|
{ 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: 'social', label: 'Social Media', icon: 'camera' },
|
||||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||||
{ id: 'system', label: 'System', icon: 'gear' },
|
{ 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: 'partner', label: 'Partner', icon: 'handshake' },
|
||||||
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||||
|
|
@ -47,6 +48,9 @@ window.Page_admin = (() => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
function _render() {
|
function _render() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
|
<!-- Action Items -->
|
||||||
|
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||||||
${TABS.map(t => `
|
${TABS.map(t => `
|
||||||
|
|
@ -72,9 +76,68 @@ window.Page_admin = (() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_renderActionItems();
|
||||||
_renderTab();
|
_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() {
|
async function _renderTab() {
|
||||||
const el = _container.querySelector('#adm-content');
|
const el = _container.querySelector('#adm-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -93,6 +156,7 @@ window.Page_admin = (() => {
|
||||||
case 'partner': await _renderPartner(el); break;
|
case 'partner': await _renderPartner(el); break;
|
||||||
case 'outreach': await _renderOutreach(el); break;
|
case 'outreach': await _renderOutreach(el); break;
|
||||||
case 'audit': await _renderAudit(el); break;
|
case 'audit': await _renderAudit(el); break;
|
||||||
|
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||||
|
|
@ -1396,6 +1460,43 @@ window.Page_admin = (() => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// TAB: MODERATION
|
// 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) {
|
async function _renderModeration(el) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||||
|
|
@ -1410,12 +1511,52 @@ window.Page_admin = (() => {
|
||||||
async function _loadModeration(el) {
|
async function _loadModeration(el) {
|
||||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
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/zuchter/pending').catch(() => []),
|
||||||
API.get('/wiki/foto-submissions').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 ---
|
// --- Züchter-Einreichungen ---
|
||||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
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)">
|
margin-bottom:var(--space-3)">
|
||||||
Züchter-Einreichungen
|
Züchter-Einreichungen
|
||||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
<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>`;
|
</h3>`;
|
||||||
|
|
||||||
if (!zuchter.length) {
|
if (!zuchterPending.length) {
|
||||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
|
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
|
||||||
} else {
|
} 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">
|
<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">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>
|
</tr></thead><tbody>
|
||||||
${zuchter.map((z, i) => `
|
${zuchterPending.map((z, i) => `
|
||||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
<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" 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.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">${_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">${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">${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">
|
<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>
|
<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('')}
|
</tr>`).join('')}
|
||||||
</tbody></table></div></div>`;
|
</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 ---
|
// --- Wiki-Foto-Einreichungen ---
|
||||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
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)">
|
margin-bottom:var(--space-3)">
|
||||||
Wiki-Foto-Einreichungen
|
Wiki-Foto-Einreichungen
|
||||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
<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>`;
|
</h3>`;
|
||||||
|
|
||||||
if (!fotos.length) {
|
if (!fotosPending.length) {
|
||||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||||||
} else {
|
} else {
|
||||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
|
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
|
||||||
${fotos.map(f => `
|
${fotosPending.map(f => `
|
||||||
<div class="card" style="padding:var(--space-4)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
<img src="${_esc(f.foto_url)}" alt=""
|
<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)">
|
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-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"
|
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
||||||
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
|
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
|
||||||
opacity:.5;margin-bottom:var(--space-2)">
|
opacity:.5;margin-bottom:var(--space-2)">
|
||||||
|
|
@ -1480,6 +1627,111 @@ window.Page_admin = (() => {
|
||||||
</div>`;
|
</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;
|
el.innerHTML = html;
|
||||||
|
|
||||||
// Züchter freigeben
|
// Züchter freigeben
|
||||||
|
|
@ -1518,6 +1770,41 @@ window.Page_admin = (() => {
|
||||||
await _loadModeration(el);
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${log.map(l => `
|
${log.map((l, i) => `
|
||||||
<tr style="border-bottom:1px solid var(--c-border)">
|
<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)">${accountBadge(l.from_account)}</td>
|
||||||
<td style="padding:var(--space-2)">${_esc(l.recipient)}</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>
|
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
|
||||||
|
|
@ -2147,6 +2436,28 @@ window.Page_admin = (() => {
|
||||||
</div>
|
</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)} ·
|
||||||
|
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app ·
|
||||||
|
${(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
|
// Vorlage in Compose laden
|
||||||
function _loadTplIntoCompose(id) {
|
function _loadTplIntoCompose(id) {
|
||||||
const tpl = templates.find(t => t.id === id);
|
const tpl = templates.find(t => t.id === id);
|
||||||
|
|
@ -2375,6 +2686,129 @@ window.Page_admin = (() => {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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 };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
|
|
|
||||||
958
backend/static/js/pages/adoption.js
Normal file
958
backend/static/js/pages/adoption.js
Normal 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="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</div>'">`
|
||||||
|
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
|
||||||
|
|
||||||
|
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="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>'">`
|
||||||
|
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
|
||||||
|
|
||||||
|
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 6Mo–2J'
|
||||||
|
: l.alter_kategorie === 'adult' ? 'Adult 2–8J'
|
||||||
|
: 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 <6Mo</option>
|
||||||
|
<option value="jung">Jung 6Mo–2J</option>
|
||||||
|
<option value="adult">Adult 2–8J</option>
|
||||||
|
<option value="senior">Senior >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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// PUBLIC API
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return { init, refresh };
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -187,16 +187,14 @@ window.Page_dog_profile = (() => {
|
||||||
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
||||||
Profil bearbeiten
|
Profil bearbeiten
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
|
||||||
<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">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
||||||
Teilen
|
Teilen
|
||||||
</button>` : ''}
|
</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">
|
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
||||||
+ Weiteren Hund anlegen
|
+ Weiteren Hund anlegen
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
|
|
@ -209,7 +207,8 @@ window.Page_dog_profile = (() => {
|
||||||
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
||||||
<div style="font-weight:600">Sitter-Zugang</div>
|
<div style="font-weight:600">Sitter-Zugang</div>
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||||
Gib einem Freund temporären Schreibzugang für diesen Hund
|
Gib einem Freund temporären Schreibzugang für diesen Hund.
|
||||||
|
Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
||||||
|
|
@ -257,14 +256,14 @@ window.Page_dog_profile = (() => {
|
||||||
_showChipEdit(dog);
|
_showChipEdit(dog);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => {
|
|
||||||
_showAusweisModal(dog.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
|
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
|
||||||
_showShareModal(dog);
|
_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.
|
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,13 +744,7 @@ window.Page_dog_profile = (() => {
|
||||||
// AUSWEIS
|
// AUSWEIS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _showAusweisModal(dogId) {
|
function _showAusweisModal(dogId) {
|
||||||
UI.modal.open({
|
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -996,7 +989,7 @@ window.Page_dog_profile = (() => {
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Foto</label>
|
<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"
|
<img id="dp-form-preview"
|
||||||
src="${dog?.foto_url || ''}"
|
src="${dog?.foto_url || ''}"
|
||||||
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
|
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"
|
<input type="file" name="foto" accept="image/*" style="display:none"
|
||||||
id="dp-form-foto">
|
id="dp-form-foto">
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1086,6 +1089,9 @@ window.Page_dog_profile = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rassen-Erkennung per KI
|
||||||
|
_bindRasseErkennung();
|
||||||
|
|
||||||
document.getElementById('dp-form-cancel')
|
document.getElementById('dp-form-cancel')
|
||||||
?.addEventListener('click', UI.modal.close);
|
?.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
|
// HELPER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1196,6 +1348,444 @@ window.Page_dog_profile = (() => {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
828
backend/static/js/pages/expenses.js
Normal file
828
backend/static/js/pages/expenses.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, refresh };
|
||||||
|
})();
|
||||||
|
|
@ -66,6 +66,7 @@ window.Page_forum = (() => {
|
||||||
_container = container;
|
_container = container;
|
||||||
_appState = appState;
|
_appState = appState;
|
||||||
_render();
|
_render();
|
||||||
|
_loadHdmCard();
|
||||||
_loadThreads(true);
|
_loadThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,15 +99,17 @@ window.Page_forum = (() => {
|
||||||
<div class="forum-category-tabs by-tabs" id="forum-tabs">
|
<div class="forum-category-tabs by-tabs" id="forum-tabs">
|
||||||
${KATEGORIEN.map(k => `
|
${KATEGORIEN.map(k => `
|
||||||
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
|
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
|
||||||
data-kat="${k.key}">${_esc(k.label)}</button>
|
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
|
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
|
||||||
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
|
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rechte Spalte: Suche + Threads -->
|
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
|
||||||
<div class="forum-main-col">
|
<div class="forum-main-col">
|
||||||
|
|
||||||
|
<div id="forum-hdm-card"></div>
|
||||||
|
|
||||||
<div class="forum-search-wrap">
|
<div class="forum-search-wrap">
|
||||||
<input type="search" class="forum-search" id="forum-search"
|
<input type="search" class="forum-search" id="forum-search"
|
||||||
placeholder="Forum durchsuchen…" autocomplete="off">
|
placeholder="Forum durchsuchen…" autocomplete="off">
|
||||||
|
|
@ -127,6 +130,23 @@ window.Page_forum = (() => {
|
||||||
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
|
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
|
||||||
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
|
_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
|
// Tab-Klicks
|
||||||
_tabsEl.addEventListener('click', e => {
|
_tabsEl.addEventListener('click', e => {
|
||||||
const btn = e.target.closest('[data-kat], [data-section]');
|
const btn = e.target.closest('[data-kat], [data-section]');
|
||||||
|
|
@ -175,6 +195,177 @@ window.Page_forum = (() => {
|
||||||
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
|
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
|
// Threads laden
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
|
|
||||||
window.Page_health = (() => {
|
window.Page_health = (() => {
|
||||||
|
|
||||||
let _container = null;
|
let _container = null;
|
||||||
let _appState = null;
|
let _appState = null;
|
||||||
let _data = {};
|
let _data = {};
|
||||||
let _praxen = [];
|
let _praxen = [];
|
||||||
let _activeTab = 'impfung';
|
let _activeTab = 'impfung';
|
||||||
|
let _favoritVet = null;
|
||||||
|
let _healthDocs = [];
|
||||||
|
|
||||||
const BASE_TABS = [
|
const BASE_TABS = [
|
||||||
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
{ 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: '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: '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: '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>' };
|
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">
|
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||||
${UI.icon('star')} KI-Zusammenfassung
|
${UI.icon('star')} KI-Zusammenfassung
|
||||||
</button>
|
</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>
|
</div>
|
||||||
${transponderHtml}
|
${transponderHtml}
|
||||||
<div id="health-ki-berichte"></div>
|
<div id="health-ki-berichte"></div>
|
||||||
|
|
@ -162,6 +166,8 @@ window.Page_health = (() => {
|
||||||
_renderTabBar();
|
_renderTabBar();
|
||||||
_container.querySelector('#health-ki-btn')
|
_container.querySelector('#health-ki-btn')
|
||||||
.addEventListener('click', _showKiSummary);
|
.addEventListener('click', _showKiSummary);
|
||||||
|
_container.querySelector('#health-ki-tierarzt-btn')
|
||||||
|
.addEventListener('click', _showKiTierarzt);
|
||||||
_container.querySelector('#health-transponder-edit')
|
_container.querySelector('#health-transponder-edit')
|
||||||
.addEventListener('click', () => _editTransponder(dog));
|
.addEventListener('click', () => _editTransponder(dog));
|
||||||
|
|
||||||
|
|
@ -170,6 +176,7 @@ window.Page_health = (() => {
|
||||||
_renderTab();
|
_renderTab();
|
||||||
_loadKiBerichte(dog.id);
|
_loadKiBerichte(dog.id);
|
||||||
_loadTerminvorschlaege(dog.id);
|
_loadTerminvorschlaege(dog.id);
|
||||||
|
_loadMeinTierarzt();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -342,6 +349,16 @@ window.Page_health = (() => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_data['gewicht_chart'] = [];
|
_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 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||||
case 'symptomcheck': _renderSymptomCheck(content); break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_bindTabEvents(content);
|
_bindTabEvents(content);
|
||||||
|
|
@ -901,7 +917,8 @@ window.Page_health = (() => {
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="health-list">${items}</div>
|
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
|
// Praxis hinzufügen
|
||||||
content.querySelector('[data-action="add-praxis"]')
|
content.querySelector('[data-action="add-praxis"]')
|
||||||
?.addEventListener('click', () => _showPraxForm(null));
|
?.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
|
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' : ''}"
|
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
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>
|
<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()">
|
onclick="event.stopPropagation()">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
||||||
</a>` : ''}
|
</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>
|
</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 `
|
return `
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||||
${addBtn}
|
${addBtn}
|
||||||
</div>
|
</div>
|
||||||
|
${favCard}
|
||||||
<div class="health-list">
|
<div class="health-list">
|
||||||
${aktive.map(renderCard).join('')}
|
${ohneGesetzt.map(renderCard).join('')}
|
||||||
${inaktive.length ? `
|
${inaktive.length ? `
|
||||||
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
||||||
border-top:1px solid var(--c-border)">
|
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 & 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() {
|
async function _showKiSummary() {
|
||||||
const btn = _container.querySelector('#health-ki-btn');
|
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>⚠️ 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>⚠️ 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 };
|
return { init, refresh, openNew, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
269
backend/static/js/pages/jobs.js
Normal file
269
backend/static/js/pages/jobs.js
Normal 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 & 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', '1–2 Posts pro Woche auf Instagram & 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 };
|
||||||
|
})();
|
||||||
|
|
@ -13,6 +13,9 @@ window.Page_movies = (() => {
|
||||||
let _filme = [];
|
let _filme = [];
|
||||||
let _activeTab = 'filme';
|
let _activeTab = 'filme';
|
||||||
let _filter = 'alle';
|
let _filter = 'alle';
|
||||||
|
let _typ = 'alle'; // alle | film | serie | doku
|
||||||
|
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
|
||||||
|
let _search = '';
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// INIT
|
// INIT
|
||||||
|
|
@ -39,7 +42,6 @@ window.Page_movies = (() => {
|
||||||
<div class="movies-tabs">
|
<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 === '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 === '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>
|
||||||
<div id="movies-tab-content"></div>
|
<div id="movies-tab-content"></div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -64,26 +66,54 @@ window.Page_movies = (() => {
|
||||||
|
|
||||||
if (_activeTab === 'filme') await _renderFilme(content);
|
if (_activeTab === 'filme') await _renderFilme(content);
|
||||||
if (_activeTab === 'promis') _renderPromis(content);
|
if (_activeTab === 'promis') _renderPromis(content);
|
||||||
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// TAB 1: FILME
|
// TAB 1: FILME
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
async function _loadFilme() {
|
||||||
|
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function _renderFilme(content) {
|
async function _renderFilme(content) {
|
||||||
try {
|
try {
|
||||||
_filme = await API.get('/movies/filme');
|
await _loadFilme();
|
||||||
} catch {
|
} catch {
|
||||||
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
|
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="movies-filter-row">
|
<div class="movies-controls">
|
||||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
<div class="movies-search-row">
|
||||||
<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>
|
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||||
<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>
|
<input type="search" id="movies-search" class="form-control movies-search-input"
|
||||||
<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>
|
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 A–Z</option>
|
||||||
|
</select>
|
||||||
|
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="movie-grid" id="movie-grid"></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'));
|
_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 === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
|
||||||
if (_filter === 'ueberlebt') 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) {
|
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>`;
|
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) {
|
function _movieCard(film) {
|
||||||
const stirbt = film.stirbt_der_hund;
|
const stirbt = film.stirbt_der_hund;
|
||||||
const tag = stirbt
|
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-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> Der Hund überlebt</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 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 `
|
return `
|
||||||
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
||||||
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
||||||
<div class="movie-card-body">
|
<div class="movie-card-body">
|
||||||
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
||||||
<div class="movie-card-genre">${_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>
|
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
|
||||||
${tag}
|
${tag}
|
||||||
|
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
|
||||||
<div class="movie-card-stars">${stars}</div>
|
<div class="movie-card-stars">${stars}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
708
backend/static/js/pages/playdate.js
Normal file
708
backend/static/js/pages/playdate.js
Normal 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, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
188
backend/static/js/pages/recalls.js
Normal file
188
backend/static/js/pages/recalls.js
Normal 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 };
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -263,6 +263,12 @@ window.Page_settings = (() => {
|
||||||
<span>Kalender abonnieren</span>
|
<span>Kalender abonnieren</span>
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-item" id="settings-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"
|
<div class="sidebar-item" id="settings-logout-btn"
|
||||||
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
||||||
color:var(--c-danger)">
|
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 () => {
|
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title : 'Abmelden?',
|
title : 'Abmelden?',
|
||||||
|
|
@ -1238,6 +1249,49 @@ window.Page_settings = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// NICHT EINGELOGGT — Login / Registrierung
|
// 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) {
|
function _renderAuth(mode) {
|
||||||
// Passwort-Reset über Link aus E-Mail
|
// Passwort-Reset über Link aus E-Mail
|
||||||
const resetToken = sessionStorage.getItem('by_reset_token');
|
const resetToken = sessionStorage.getItem('by_reset_token');
|
||||||
|
|
@ -1467,7 +1521,16 @@ window.Page_settings = (() => {
|
||||||
const fd = UI.formData(e.target);
|
const fd = UI.formData(e.target);
|
||||||
|
|
||||||
await UI.asyncButton(btn, async () => {
|
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);
|
localStorage.setItem('by_token', result.token);
|
||||||
|
|
||||||
// User-Daten laden
|
// User-Daten laden
|
||||||
|
|
@ -1583,22 +1646,12 @@ window.Page_settings = (() => {
|
||||||
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
||||||
const finalCode = partnerCode || refCode || undefined;
|
const finalCode = partnerCode || refCode || undefined;
|
||||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
|
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');
|
if (refCode) sessionStorage.removeItem('by_ref_code');
|
||||||
|
|
||||||
_appState.user = await API.auth.me();
|
if (result.pending_verification) {
|
||||||
document.getElementById('sidebar-username').textContent = _appState.user.name;
|
_renderVerifyPending(fd.email);
|
||||||
_appState.dogs = [];
|
return;
|
||||||
_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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
|
||||||
_closeModal();
|
_closeModal();
|
||||||
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
|
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) {
|
if (resp.ist_top) {
|
||||||
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,8 @@ window.Page_welcome = (() => {
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
|
||||||
|
|
||||||
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
|
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
|
||||||
<div class="wc-grid">
|
<div class="wc-grid">
|
||||||
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
|
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
|
||||||
|
|
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
|
||||||
_updateChipsFromDash(dash);
|
_updateChipsFromDash(dash);
|
||||||
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
|
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
|
||||||
}).catch(() => { /* Skeleton bleibt sichtbar */ });
|
}).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) {
|
function _updateHeroFromDash(dash, dog) {
|
||||||
const heroBox = _container.querySelector('#wc-hero-box');
|
const heroBox = _container.querySelector('#wc-hero-box');
|
||||||
if (!heroBox) return;
|
if (!heroBox) return;
|
||||||
|
|
|
||||||
581
backend/static/js/pages/wetter.js
Normal file
581
backend/static/js/pages/wetter.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// PUBLIC API
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return { init, refresh };
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -255,6 +255,15 @@ window.Page_wiki = (() => {
|
||||||
<option value="">Alle Gruppen</option>
|
<option value="">Alle Gruppen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 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">
|
<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>
|
<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)
|
// Load initial batch (also populates gruppen)
|
||||||
await _loadBreeds(el, true);
|
await _loadBreeds(el, true);
|
||||||
|
|
||||||
|
// Rassen-Erkennung per KI
|
||||||
|
_bindWikiRasseErkennung(el);
|
||||||
|
|
||||||
// Search handler with debounce
|
// Search handler with debounce
|
||||||
let _searchTimer;
|
let _searchTimer;
|
||||||
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
||||||
|
|
@ -1265,6 +1277,130 @@ window.Page_wiki = (() => {
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
1071
backend/static/js/worlds.js
Normal file
1071
backend/static/js/worlds.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,16 +3,16 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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 Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Check, Giftköder-Alarm — DSGVO-konform, ohne App Store.">
|
<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, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community">
|
<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">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="canonical" href="https://banyaro.app/info">
|
<link rel="canonical" href="https://banyaro.app/info">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:type" content="website">
|
<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:title" content="Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz">
|
||||||
<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: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:url" content="https://banyaro.app/info">
|
||||||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||||||
<meta property="og:locale" content="de_DE">
|
<meta property="og:locale" content="de_DE">
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<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:title" content="Ban Yaro — Die Hunde-App für DACH">
|
||||||
<meta name="twitter:description" content="Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check — und alles rund um deinen Hund. Kostenlos, DSGVO-konform.">
|
<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">
|
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
|
||||||
|
|
||||||
<!-- Structured Data -->
|
<!-- Structured Data -->
|
||||||
|
|
@ -50,42 +50,53 @@
|
||||||
"url": "https://banyaro.app"
|
"url": "https://banyaro.app"
|
||||||
},
|
},
|
||||||
"featureList": [
|
"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",
|
"Digitaler Impfpass und Gesundheitsakte",
|
||||||
"Pflege-System mit 43 rassenspezifischen Tipps in 10 Kategorien",
|
"Pflege-System mit 43 rassenspezifischen Tipps in 10 Kategorien",
|
||||||
"Giftköder-Alarm mit Push-Benachrichtigungen",
|
"Giftköder-Alarm mit GPS und Push-Benachrichtigungen",
|
||||||
"Zecken-Warnung regelbasiert (Saison + Temperatur)",
|
"Zecken-Warnung regelbasiert (Saison und Temperatur)",
|
||||||
"Wetter-Chip in der App (Open-Meteo, ohne API-Key)",
|
"Wetter-Chip in der App (Open-Meteo, ohne API-Key)",
|
||||||
"Gassi-Community und GPS-Routen",
|
"Gassi-Community und GPS-Routen aufzeichnen",
|
||||||
"Hundesitting-Vermittlung",
|
"Täglicher Routenvorschlag (2, 4 oder 6 km via OpenRouteService)",
|
||||||
"NFC-Halsband-Tags",
|
"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",
|
"1003 Hunderassen Wikipedia-grounded und KI-angereichert",
|
||||||
"Community-Fotos im Rassen-Wiki mit Bildrechte-Bestätigung und Moderation",
|
"Rassen-Wiki mit Community-Fotos und Moderation",
|
||||||
"Rassen-Quiz Passt diese Rasse zu mir?",
|
"Verlorener Hund Alarm mit GPS-Position",
|
||||||
"Verlorener Hund Alarm",
|
"Offline-Modus via Service Worker (3 Stufen)",
|
||||||
"Forum für Hundebesitzer",
|
|
||||||
"Offline-Modus via Service Worker",
|
|
||||||
"Symptom-Checker (KI, kostenlos)",
|
"Symptom-Checker (KI, kostenlos)",
|
||||||
"Trainings-Tagebuch mit Einheiten-Logging (Wiederholungen, Erfolgsquote, Stimmung)",
|
"104 Trainingsübungen in Datenbank mit Schwierigkeitsgraden",
|
||||||
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose",
|
"Trainings-Logging: Wiederholungen, Erfolgsquote, Stimmung",
|
||||||
"Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche",
|
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen",
|
||||||
"Trainings-Gamification: Streaks, Abzeichen, Trainingskalender",
|
"Wöchentlicher KI-Lober jeden Montag",
|
||||||
"Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter",
|
"Trainings-Streaks und Trainingskalender",
|
||||||
"Wurfbörse — öffentliche Wurfankündigungen mit Filtersuche nach Rasse und Status",
|
"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",
|
"Züchter-Profile mit verifizierten Gesundheitstests und Gentests",
|
||||||
"Stammbaum-Visualisierung bis 4 Generationen",
|
"Stammbaum-Visualisierung bis 4 Generationen",
|
||||||
"Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung",
|
"Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung",
|
||||||
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
|
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
|
||||||
"Tierschutz-Check automatisch bei jeder Verpaarung — nicht abschaltbar",
|
"Tierschutz-Check automatisch bei jeder Verpaarung",
|
||||||
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik-Erklärung, Paarungsanalyse",
|
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik, Paarungsanalyse",
|
||||||
"Datenexport als HTML und ODS — keine Datenfalle",
|
"Datenexport als HTML und ODS",
|
||||||
"Personalisierte Tagesroute via OpenRouteService — täglich neue Gassirunde mit 2/4/6 km Wahl",
|
"Hunde-Filmdatenbank: 68 Filme, Serien und Dokumentationen sortier- und filterbar",
|
||||||
"Übung des Tages — personalisiert aus dem persönlichen Trainingsfortschritt",
|
"Filmdatenbank-Feature: Stirbt der Hund? — Taschentuch-Warnung",
|
||||||
"Dashboard-Startseite mit Hundebild-Hero, Statistik-Chips und Feature-Karten"
|
"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",
|
"screenshot": "https://banyaro.app/icons/icon-512.png",
|
||||||
"softwareVersion": "2.2",
|
"softwareVersion": "1.2.1",
|
||||||
"datePublished": "2026-04-29",
|
"datePublished": "2026-05-01",
|
||||||
"areaServed": ["DE", "AT", "CH"],
|
"areaServed": ["DE", "AT", "CH"],
|
||||||
"audience": {
|
"audience": {
|
||||||
"@type": "Audience",
|
"@type": "Audience",
|
||||||
|
|
@ -510,7 +521,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="feature-icon">💬</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -528,7 +539,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="feature-icon">🎬</span>
|
<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>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="feature-icon">🩹</span>
|
<span class="feature-icon">🩹</span>
|
||||||
|
|
@ -604,13 +615,14 @@
|
||||||
<section id="vergleich">
|
<section id="vergleich">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Ban Yaro vs. Konkurrenz</h2>
|
<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">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Funktion</th>
|
<th>Funktion</th>
|
||||||
<th>Ban Yaro</th>
|
<th>Ban Yaro</th>
|
||||||
|
<th>Hundeo (DE)</th>
|
||||||
<th>Dogorama</th>
|
<th>Dogorama</th>
|
||||||
<th>Tractive</th>
|
<th>Tractive</th>
|
||||||
<th>PetDesk</th>
|
<th>PetDesk</th>
|
||||||
|
|
@ -621,12 +633,14 @@
|
||||||
<td>Kostenlos nutzbar</td>
|
<td>Kostenlos nutzbar</td>
|
||||||
<td class="check">✓ Ja</td>
|
<td class="check">✓ Ja</td>
|
||||||
<td>Begrenzt</td>
|
<td>Begrenzt</td>
|
||||||
|
<td>Begrenzt</td>
|
||||||
<td class="cross">✗ Abo</td>
|
<td class="cross">✗ Abo</td>
|
||||||
<td class="cross">✗ Nein</td>
|
<td class="cross">✗ Nein</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>DSGVO / EU-Hosting</td>
|
<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 class="cross">✗ Nein</td>
|
||||||
<td>Teilweise</td>
|
<td>Teilweise</td>
|
||||||
<td class="cross">✗ USA</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>
|
||||||
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Giftköder-Alarm</td>
|
<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>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Digitaler Impfpass</td>
|
<td>Digitaler Impfpass</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Gassi-Community</td>
|
<td>Forum & Community</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Hundesitting</td>
|
<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>
|
<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>
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Verlorener Hund Alarm</td>
|
<td>Verlorener Hund Alarm</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
<td class="check">✓ (GPS)</td>
|
<td class="check">✓ GPS</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Rassen-Wiki (1003 Rassen, KI-angereichert)</td>
|
<td>Rassen-Wiki (1003 Rassen, KI)</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</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>
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -700,13 +739,15 @@
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Täglicher Routenvorschlag (Gassirunde)</td>
|
<td>Täglicher Routenvorschlag</td>
|
||||||
<td class="check">✓</td>
|
<td class="check">✓</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
<td class="cross">✗</td>
|
<td class="cross">✗</td>
|
||||||
|
<td class="cross">✗</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -809,6 +850,20 @@
|
||||||
<p>Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.</p>
|
<p>Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -824,12 +879,16 @@
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p><strong style="color:white">Ban Yaro</strong> — Die deutschsprachige Hunde-Plattform</p>
|
<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">
|
<div class="footer-links">
|
||||||
<a href="/">App öffnen</a>
|
<a href="/">App öffnen</a>
|
||||||
<a href="/info">Über Ban Yaro</a>
|
<a href="/info">Über Ban Yaro</a>
|
||||||
<a href="/api/wiki/rassen">Hunde-Rassen</a>
|
<a href="/wiki/rassen">Hunde-Rassen</a>
|
||||||
<a href="/api/events">Events</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "/",
|
"id": "/",
|
||||||
"version": "1.1.4",
|
"version": "1.3.0",
|
||||||
"name": "Ban Yaro — Die Hunde-Plattform",
|
"name": "Ban Yaro — Die Hunde-Plattform",
|
||||||
"short_name": "Ban Yaro",
|
"short_name": "Ban Yaro",
|
||||||
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.",
|
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.",
|
||||||
|
|
|
||||||
320
backend/static/presse.html
Normal file
320
backend/static/presse.html
Normal 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>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v577';
|
const CACHE_VERSION = 'by-v651';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
@ -125,11 +125,34 @@ const _CACHEABLE_GET = [
|
||||||
/^\/api\/training\/progress/,
|
/^\/api\/training\/progress/,
|
||||||
/^\/api\/wiki\/rassen/,
|
/^\/api\/wiki\/rassen/,
|
||||||
/^\/api\/dogs\/\d+\/diary\/stats/,
|
/^\/api\/dogs\/\d+\/diary\/stats/,
|
||||||
|
// Drei Welten — offline-fähig
|
||||||
|
/^\/api\/streak\/\d+/,
|
||||||
|
/^\/api\/forum\/threads/,
|
||||||
|
/^\/api\/weather$/,
|
||||||
|
/^\/api\/passport\/\d+$/,
|
||||||
];
|
];
|
||||||
function _isCacheableGet(pathname) {
|
function _isCacheableGet(pathname) {
|
||||||
return _CACHEABLE_GET.some(re => re.test(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
|
// INSTALL — App Shell cachen
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -173,19 +196,27 @@ self.addEventListener('fetch', event => {
|
||||||
if (method === 'GET' && _isCacheableGet(url.pathname)) {
|
if (method === 'GET' && _isCacheableGet(url.pathname)) {
|
||||||
event.respondWith((async () => {
|
event.respondWith((async () => {
|
||||||
const cached = await caches.match(event.request);
|
const cached = await caches.match(event.request);
|
||||||
|
const stale = _cacheStale(url.pathname);
|
||||||
|
|
||||||
const networkPromise = _fetchTimeout(event.request.clone(), 8000)
|
const networkPromise = _fetchTimeout(event.request.clone(), 8000)
|
||||||
.then(resp => {
|
.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;
|
return resp;
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
// Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen
|
|
||||||
if (cached) {
|
// Cache noch frisch → sofort zurückgeben, Netz im Hintergrund
|
||||||
networkPromise.catch(() => {}); // fire and forget
|
if (cached && !stale) {
|
||||||
|
networkPromise.catch(() => {});
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
// Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback
|
||||||
const fresh = await networkPromise;
|
const fresh = await networkPromise;
|
||||||
if (fresh) return fresh;
|
if (fresh) return fresh;
|
||||||
|
if (cached) return cached; // lieber veraltet als nichts
|
||||||
return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }),
|
return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }),
|
||||||
{ status: 503, headers: { 'Content-Type': 'application/json' } });
|
{ status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||||
})());
|
})());
|
||||||
|
|
|
||||||
|
|
@ -161,3 +161,251 @@ async def get_weather_summary() -> dict:
|
||||||
|
|
||||||
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
|
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
|
||||||
return {"max_temp_c": max_temp, "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}")
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ services:
|
||||||
- "3012:8000"
|
- "3012:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- /volume1/docker/banyaro/data/media:/prod-media:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/banyaro.db
|
- DB_PATH=/data/banyaro.db
|
||||||
- MEDIA_DIR=/data/media
|
- MEDIA_DIR=/prod-media
|
||||||
- STAGING=true
|
- STAGING=true
|
||||||
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
||||||
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
|
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
|
||||||
|
|
|
||||||
0
reports/.gitkeep
Normal file
0
reports/.gitkeep
Normal file
180
reports/2026-05-01-dateien.md
Normal file
180
reports/2026-05-01-dateien.md
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
# Dateiliste — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Backend — Python-Dateien
|
||||||
|
|
||||||
|
| Datei | Größe |
|
||||||
|
| ---------------------------- | -------- |
|
||||||
|
| ._auth.py | 163.0 B |
|
||||||
|
| ._database.py | 163.0 B |
|
||||||
|
| ._ki.py | 163.0 B |
|
||||||
|
| ._main.py | 163.0 B |
|
||||||
|
| auth.py | 4.5 KB |
|
||||||
|
| content_filter.py | 2.3 KB |
|
||||||
|
| database.py | 76.6 KB |
|
||||||
|
| generate_thumbs.py | 1.0 KB |
|
||||||
|
| ki.py | 15.7 KB |
|
||||||
|
| mailer.py | 5.9 KB |
|
||||||
|
| main.py | 76.9 KB |
|
||||||
|
| media_utils.py | 7.7 KB |
|
||||||
|
| migrate_media.py | 3.3 KB |
|
||||||
|
| ratelimit.py | 4.5 KB |
|
||||||
|
| routes/.___init__.py | 163.0 B |
|
||||||
|
| routes/._auth.py | 163.0 B |
|
||||||
|
| routes/._diary.py | 163.0 B |
|
||||||
|
| routes/._dogs.py | 163.0 B |
|
||||||
|
| routes/._health.py | 163.0 B |
|
||||||
|
| routes/._ki.py | 163.0 B |
|
||||||
|
| routes/._poison.py | 163.0 B |
|
||||||
|
| routes/._push.py | 163.0 B |
|
||||||
|
| routes/__init__.py | 0.0 B |
|
||||||
|
| routes/achievements.py | 10.9 KB |
|
||||||
|
| routes/admin.py | 41.0 KB |
|
||||||
|
| routes/alerts.py | 1.5 KB |
|
||||||
|
| routes/auth.py | 13.5 KB |
|
||||||
|
| routes/breeder.py | 16.2 KB |
|
||||||
|
| routes/breeder_export.py | 22.0 KB |
|
||||||
|
| routes/breeder_photos.py | 13.4 KB |
|
||||||
|
| routes/chat.py | 10.4 KB |
|
||||||
|
| routes/diary.py | 35.8 KB |
|
||||||
|
| routes/dogs.py | 22.2 KB |
|
||||||
|
| routes/events.py | 8.9 KB |
|
||||||
|
| routes/forum.py | 27.1 KB |
|
||||||
|
| routes/friends.py | 11.8 KB |
|
||||||
|
| routes/health.py | 21.1 KB |
|
||||||
|
| routes/import_data.py | 10.0 KB |
|
||||||
|
| routes/ki.py | 2.2 KB |
|
||||||
|
| routes/knigge.py | 3.9 KB |
|
||||||
|
| routes/litters.py | 25.0 KB |
|
||||||
|
| routes/lost.py | 6.3 KB |
|
||||||
|
| routes/moderation.py | 10.0 KB |
|
||||||
|
| routes/movies.py | 10.2 KB |
|
||||||
|
| routes/notes.py | 9.5 KB |
|
||||||
|
| routes/notifications.py | 4.2 KB |
|
||||||
|
| routes/osm.py | 16.8 KB |
|
||||||
|
| routes/outreach.py | 8.9 KB |
|
||||||
|
| routes/partner.py | 7.3 KB |
|
||||||
|
| routes/places.py | 6.4 KB |
|
||||||
|
| routes/poison.py | 7.0 KB |
|
||||||
|
| routes/praise.py | 1.2 KB |
|
||||||
|
| routes/profile.py | 3.7 KB |
|
||||||
|
| routes/push.py | 5.9 KB |
|
||||||
|
| routes/ratings.py | 4.8 KB |
|
||||||
|
| routes/routen.py | 22.2 KB |
|
||||||
|
| routes/services.py | 5.1 KB |
|
||||||
|
| routes/sharing.py | 5.2 KB |
|
||||||
|
| routes/sitting.py | 10.0 KB |
|
||||||
|
| routes/sitting_access.py | 2.8 KB |
|
||||||
|
| routes/social.py | 117.2 KB |
|
||||||
|
| routes/stats.py | 1.5 KB |
|
||||||
|
| routes/tieraerzte.py | 6.1 KB |
|
||||||
|
| routes/training.py | 33.8 KB |
|
||||||
|
| routes/walks.py | 20.5 KB |
|
||||||
|
| routes/weather.py | 537.0 B |
|
||||||
|
| routes/webcal.py | 14.9 KB |
|
||||||
|
| routes/widget.py | 1.8 KB |
|
||||||
|
| routes/wiki.py | 26.6 KB |
|
||||||
|
| routes/zucht_hunde.py | 31.2 KB |
|
||||||
|
| routes/zucht_ki.py | 18.8 KB |
|
||||||
|
| scheduler.py | 32.8 KB |
|
||||||
|
| scraper/__init__.py | 0.0 B |
|
||||||
|
| scraper/breed_enricher.py | 21.5 KB |
|
||||||
|
| scraper/breed_evaluator.py | 4.9 KB |
|
||||||
|
| scraper/breeds.py | 5.9 KB |
|
||||||
|
| scraper/events_vdh.py | 10.6 KB |
|
||||||
|
| scraper/fetch_wiki_images.py | 9.0 KB |
|
||||||
|
| scraper/wikidata_breeds.py | 7.8 KB |
|
||||||
|
| scraper/wikipedia_photos.py | 6.7 KB |
|
||||||
|
| scripts/generate_reports.py | 29.4 KB |
|
||||||
|
| timeutils.py | 3.3 KB |
|
||||||
|
| username_blocklist.py | 1.2 KB |
|
||||||
|
| weather.py | 5.9 KB |
|
||||||
|
| welfare_check.py | 10.0 KB |
|
||||||
|
|
||||||
|
**Gesamt**: 85 Dateien, 1.0 MB
|
||||||
|
|
||||||
|
|
||||||
|
## Frontend — JavaScript
|
||||||
|
|
||||||
|
| Datei | Größe |
|
||||||
|
| ------------------------ | -------- |
|
||||||
|
| ._api.js | 163.0 B |
|
||||||
|
| ._app.js | 163.0 B |
|
||||||
|
| ._ui.js | 163.0 B |
|
||||||
|
| api.js | 31.2 KB |
|
||||||
|
| app.js | 38.2 KB |
|
||||||
|
| leaflet.js | 143.7 KB |
|
||||||
|
| leaflet.markercluster.js | 33.3 KB |
|
||||||
|
| pages/admin.js | 119.1 KB |
|
||||||
|
| pages/breeder.js | 8.3 KB |
|
||||||
|
| pages/chat.js | 19.0 KB |
|
||||||
|
| pages/datenschutz.js | 11.2 KB |
|
||||||
|
| pages/diary.js | 92.7 KB |
|
||||||
|
| pages/dog-profile.js | 51.5 KB |
|
||||||
|
| pages/erste-hilfe.js | 31.7 KB |
|
||||||
|
| pages/events.js | 29.8 KB |
|
||||||
|
| pages/forum.js | 52.8 KB |
|
||||||
|
| pages/friends.js | 38.6 KB |
|
||||||
|
| pages/gruender.js | 7.1 KB |
|
||||||
|
| pages/health.js | 107.5 KB |
|
||||||
|
| pages/impressum.js | 3.9 KB |
|
||||||
|
| pages/knigge.js | 16.9 KB |
|
||||||
|
| pages/litters.js | 51.6 KB |
|
||||||
|
| pages/lost.js | 30.3 KB |
|
||||||
|
| pages/map.js | 70.7 KB |
|
||||||
|
| pages/moderation.js | 23.0 KB |
|
||||||
|
| pages/movies.js | 18.6 KB |
|
||||||
|
| pages/notes.js | 38.1 KB |
|
||||||
|
| pages/notifications.js | 12.0 KB |
|
||||||
|
| pages/onboarding.js | 17.2 KB |
|
||||||
|
| pages/places.js | 19.7 KB |
|
||||||
|
| pages/poison.js | 26.9 KB |
|
||||||
|
| pages/routes.js | 132.6 KB |
|
||||||
|
| pages/settings.js | 84.2 KB |
|
||||||
|
| pages/sitting.js | 33.9 KB |
|
||||||
|
| pages/social.js | 74.3 KB |
|
||||||
|
| pages/trainingsplaene.js | 40.0 KB |
|
||||||
|
| pages/uebungen.js | 98.8 KB |
|
||||||
|
| pages/walks.js | 42.4 KB |
|
||||||
|
| pages/welcome.js | 51.1 KB |
|
||||||
|
| pages/widget.js | 5.6 KB |
|
||||||
|
| pages/wiki.js | 55.9 KB |
|
||||||
|
| pages/wurfboerse.js | 9.7 KB |
|
||||||
|
| pages/zucht-profil.js | 23.6 KB |
|
||||||
|
| pages/zuchthunde.js | 67.0 KB |
|
||||||
|
| qrcode.min.js | 19.5 KB |
|
||||||
|
| ui.js | 34.8 KB |
|
||||||
|
|
||||||
|
**Gesamt**: 46 Dateien, 1.9 MB
|
||||||
|
|
||||||
|
|
||||||
|
## Frontend — CSS
|
||||||
|
|
||||||
|
| Datei | Größe |
|
||||||
|
| ------------------------- | -------- |
|
||||||
|
| ._components.css | 163.0 B |
|
||||||
|
| ._design-system.css | 163.0 B |
|
||||||
|
| ._layout.css | 163.0 B |
|
||||||
|
| MarkerCluster.Default.css | 1.3 KB |
|
||||||
|
| MarkerCluster.css | 872.0 B |
|
||||||
|
| components.css | 178.5 KB |
|
||||||
|
| design-system.css | 10.0 KB |
|
||||||
|
| layout.css | 20.7 KB |
|
||||||
|
| leaflet.css | 14.2 KB |
|
||||||
|
|
||||||
|
**Gesamt**: 9 Dateien, 226.1 KB
|
||||||
|
|
||||||
|
|
||||||
|
## Frontend — HTML
|
||||||
|
|
||||||
|
| Datei | Größe |
|
||||||
|
| ------------ | ------- |
|
||||||
|
| ._index.html | 163.0 B |
|
||||||
|
| index.html | 25.3 KB |
|
||||||
|
| landing.html | 35.2 KB |
|
||||||
|
|
||||||
151
reports/2026-05-01-funktionsumfang.md
Normal file
151
reports/2026-05-01-funktionsumfang.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Funktionsumfang — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 1–3)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Backend-Routers
|
||||||
|
|
||||||
|
| 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 |
|
||||||
91
reports/2026-05-01-nutzer.md
Normal file
91
reports/2026-05-01-nutzer.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Nutzerübersicht — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Nutzer nach Rolle
|
||||||
|
|
||||||
|
| Gruppe | Anzahl |
|
||||||
|
| -------------------- | ------ |
|
||||||
|
| Gesamt Nutzer | 5 |
|
||||||
|
| Admin | 1 |
|
||||||
|
| Moderatoren | 2 |
|
||||||
|
| Züchter | 0 |
|
||||||
|
| Gründer (aktiv) | 0 |
|
||||||
|
| Partner | 1 |
|
||||||
|
| Premium | 0 |
|
||||||
|
| Gesperrt (banned) | 0 |
|
||||||
|
| E-Mail unverifiziert | 4 |
|
||||||
|
|
||||||
|
## Registrierungen (letzte 6 Monate)
|
||||||
|
|
||||||
|
| Monat | Neue Nutzer |
|
||||||
|
| ------- | ----------- |
|
||||||
|
| 2026-04 | 5 |
|
||||||
|
|
||||||
|
|
||||||
|
## Hunde
|
||||||
|
|
||||||
|
| Metrik | Anzahl |
|
||||||
|
| ---------------------------- | ------ |
|
||||||
|
| Hunde gesamt | 4 |
|
||||||
|
| Hunde mit Tagebuch-Einträgen | 3 |
|
||||||
|
|
||||||
|
|
||||||
|
## Forum
|
||||||
|
|
||||||
|
| Metrik | Anzahl |
|
||||||
|
| ---------------- | ------ |
|
||||||
|
| Threads | 10 |
|
||||||
|
| Antworten | 7 |
|
||||||
|
| Offene Meldungen | 0 |
|
||||||
|
|
||||||
|
**Threads nach Kategorie:**
|
||||||
|
|
||||||
|
| Kategorie | Threads |
|
||||||
|
| ----------- | ------- |
|
||||||
|
| rasse | 3 |
|
||||||
|
| spaziergang | 3 |
|
||||||
|
| allgemein | 2 |
|
||||||
|
| ausflug | 2 |
|
||||||
|
|
||||||
|
|
||||||
|
## Tagebuch
|
||||||
|
|
||||||
|
| Metrik | Anzahl |
|
||||||
|
| ------------------- | ------ |
|
||||||
|
| Einträge gesamt | 117 |
|
||||||
|
| Mit Foto | 0 |
|
||||||
|
| Mit GPS-Koordinaten | 0 |
|
||||||
|
|
||||||
|
|
||||||
|
## Medien auf dem Server
|
||||||
|
|
||||||
|
| Verzeichnis | Dateien | Größe |
|
||||||
|
| ----------- | ------- | -------- |
|
||||||
|
| avatars | 4 | 7.1 MB |
|
||||||
|
| breeds | 820 | 212.5 MB |
|
||||||
|
| diary | 311 | 215.6 MB |
|
||||||
|
| dogs | 10 | 39.8 MB |
|
||||||
|
| forum | 44 | 112.1 MB |
|
||||||
|
| poison | 0 | 0.0 B |
|
||||||
|
| routes | 1 | 6.6 MB |
|
||||||
|
| **GESAMT** | 1190 | 593.6 MB |
|
||||||
|
|
||||||
|
|
||||||
|
## Gesendete E-Mails
|
||||||
|
|
||||||
|
| Absender | Anzahl | Erste Mail | Letzte Mail |
|
||||||
|
| -------- | ------ | ---------- | ----------- |
|
||||||
|
| partner | 9 | 2026-04-30 | 2026-04-30 |
|
||||||
|
|
||||||
|
**Gesamt**: 9 Mails gesendet
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
24
reports/2026-05-01-partner.md
Normal file
24
reports/2026-05-01-partner.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Partnerliste — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Partner-Accounts
|
||||||
|
|
||||||
|
| Name | E-Mail | Partner seit | Gründer-Nr. |
|
||||||
|
| ---- | ---------------- | ------------ | ----------- |
|
||||||
|
| René | mail@motocamp.de | 2026-04-12 | — |
|
||||||
|
|
||||||
|
|
||||||
|
## Partner-Codes
|
||||||
|
|
||||||
|
_Keine Partner-Codes_
|
||||||
|
|
||||||
|
|
||||||
|
## Gründer
|
||||||
|
|
||||||
|
_Noch keine Gründer_
|
||||||
|
|
||||||
172
reports/2026-05-01-server.md
Normal file
172
reports/2026-05-01-server.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Server & Speicherbelegung — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Festplattenbelegung
|
||||||
|
|
||||||
|
```
|
||||||
|
Filesystem Size Used Avail Use% Mounted on
|
||||||
|
/dev/mapper/cachedev_0 25T 14T 11T 58% /data
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Media-Verzeichnisse
|
||||||
|
|
||||||
|
```
|
||||||
|
217M /data/media/diary
|
||||||
|
215M /data/media/breeds
|
||||||
|
113M /data/media/forum
|
||||||
|
40M /data/media/dogs
|
||||||
|
7.1M /data/media/avatars
|
||||||
|
6.6M /data/media/routes
|
||||||
|
0 /data/media/poison
|
||||||
|
|
||||||
|
Gesamt: 596M /data/media
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Datenbank
|
||||||
|
|
||||||
|
**DB-Größe:** 62M /data/banyaro.db
|
||||||
|
|
||||||
|
| Tabelle | Zeilen |
|
||||||
|
| ---------------------- | ------- |
|
||||||
|
| osm_pois | 440,865 |
|
||||||
|
| osm_tiles | 7,613 |
|
||||||
|
| wiki_rassen | 1,003 |
|
||||||
|
| diary_dogs | 118 |
|
||||||
|
| diary | 117 |
|
||||||
|
| training_exercises | 110 |
|
||||||
|
| diary_media | 101 |
|
||||||
|
| pflege_tipps | 45 |
|
||||||
|
| sqlite_sequence | 42 |
|
||||||
|
| push_subscriptions | 26 |
|
||||||
|
| user_badges | 22 |
|
||||||
|
| route_walks | 19 |
|
||||||
|
| notifications | 17 |
|
||||||
|
| exercise_progress | 15 |
|
||||||
|
| routes | 13 |
|
||||||
|
| user_map_pois | 13 |
|
||||||
|
| knigge_votes | 12 |
|
||||||
|
| forum_threads | 11 |
|
||||||
|
| health | 11 |
|
||||||
|
| direct_messages | 10 |
|
||||||
|
| outreach_log | 9 |
|
||||||
|
| forum_posts | 8 |
|
||||||
|
| forum_likes | 7 |
|
||||||
|
| poison | 6 |
|
||||||
|
| events | 5 |
|
||||||
|
| ki_daily_calls | 5 |
|
||||||
|
| training_sessions | 5 |
|
||||||
|
| users | 5 |
|
||||||
|
| dogs | 4 |
|
||||||
|
| ki_health_reports | 4 |
|
||||||
|
| social_content | 4 |
|
||||||
|
| weekly_praise | 4 |
|
||||||
|
| ors_daily_total | 3 |
|
||||||
|
| walks | 3 |
|
||||||
|
| friendships | 2 |
|
||||||
|
| zucht_hunde | 2 |
|
||||||
|
| admin_audit | 1 |
|
||||||
|
| breeder_jahresberichte | 1 |
|
||||||
|
| breeder_profiles | 1 |
|
||||||
|
| conversations | 1 |
|
||||||
|
| dog_shares | 1 |
|
||||||
|
| email_templates | 1 |
|
||||||
|
| hund_des_monats_votes | 1 |
|
||||||
|
| notes | 1 |
|
||||||
|
| ratings | 1 |
|
||||||
|
| tieraerzte | 1 |
|
||||||
|
| training_ki_cache | 1 |
|
||||||
|
| wiki_breed_interest | 1 |
|
||||||
|
| wiki_foto_submissions | 1 |
|
||||||
|
| breeder_documents | 0 |
|
||||||
|
| breeder_photos | 0 |
|
||||||
|
| dog_genetic_tests | 0 |
|
||||||
|
| dog_health_tests | 0 |
|
||||||
|
| dog_titles | 0 |
|
||||||
|
| event_rsvp | 0 |
|
||||||
|
| forum_reports | 0 |
|
||||||
|
| health_media | 0 |
|
||||||
|
| litters | 0 |
|
||||||
|
| lost_dogs | 0 |
|
||||||
|
| movie_votes | 0 |
|
||||||
|
| osm_poi_edits | 0 |
|
||||||
|
| osm_reports | 0 |
|
||||||
|
| partner_codes | 0 |
|
||||||
|
| places | 0 |
|
||||||
|
| premium_orders | 0 |
|
||||||
|
| puppies | 0 |
|
||||||
|
| puppy_weights | 0 |
|
||||||
|
| route_suggest_usage | 0 |
|
||||||
|
| service_offers | 0 |
|
||||||
|
| sitters | 0 |
|
||||||
|
| sitting_requests | 0 |
|
||||||
|
| sitting_subscriptions | 0 |
|
||||||
|
| training_plan_progress | 0 |
|
||||||
|
| walk_invitations | 0 |
|
||||||
|
| walk_participant_dogs | 0 |
|
||||||
|
| walk_participants | 0 |
|
||||||
|
| wiki_berichte | 0 |
|
||||||
|
| wiki_zuchter | 0 |
|
||||||
|
|
||||||
|
|
||||||
|
## App-Code
|
||||||
|
|
||||||
|
**App-Verzeichnis (/app):** 8.9M /app
|
||||||
|
|
||||||
|
|
||||||
|
## Kapazitäts-Warnung
|
||||||
|
|
||||||
|
> ✅ 58 % Festplatte belegt — ausreichend Kapazität.
|
||||||
|
|
||||||
|
|
||||||
|
## Installierte Python-Pakete
|
||||||
|
|
||||||
|
```
|
||||||
|
Package Version
|
||||||
|
------------------ ------------
|
||||||
|
aiohappyeyeballs 2.6.1
|
||||||
|
aiohttp 3.13.5
|
||||||
|
aiosignal 1.4.0
|
||||||
|
annotated-types 0.7.0
|
||||||
|
anthropic 0.49.0
|
||||||
|
anyio 4.13.0
|
||||||
|
APScheduler 3.10.4
|
||||||
|
attrs 26.1.0
|
||||||
|
bcrypt 4.3.0
|
||||||
|
certifi 2026.4.22
|
||||||
|
cffi 2.0.0
|
||||||
|
charset-normalizer 3.4.7
|
||||||
|
click 8.3.3
|
||||||
|
cryptography 47.0.0
|
||||||
|
defusedxml 0.7.1
|
||||||
|
distro 1.9.0
|
||||||
|
dnspython 2.8.0
|
||||||
|
email-validator 2.3.0
|
||||||
|
fastapi 0.115.0
|
||||||
|
frozenlist 1.8.0
|
||||||
|
h11 0.16.0
|
||||||
|
http_ece 1.2.1
|
||||||
|
httpcore 1.0.9
|
||||||
|
httptools 0.7.1
|
||||||
|
httpx 0.28.1
|
||||||
|
idna 3.13
|
||||||
|
jiter 0.14.0
|
||||||
|
multidict 6.7.1
|
||||||
|
odfpy 1.4.1
|
||||||
|
openai 1.59.2
|
||||||
|
pillow 11.2.1
|
||||||
|
pillow_heif 0.22.0
|
||||||
|
pip 25.0.1
|
||||||
|
polyline 2.0.2
|
||||||
|
propcache 0.4.1
|
||||||
|
py-vapid 1.9.4
|
||||||
|
pycparser 3.0
|
||||||
|
pydantic 2.10.6
|
||||||
|
```
|
||||||
|
|
||||||
128
reports/2026-05-01-sicherheit.md
Normal file
128
reports/2026-05-01-sicherheit.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# Sicherheitsbericht — Ban Yaro
|
||||||
|
|
||||||
|
_Erstellt: 01.05.2026 06:07_
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Übersicht implementierter Schutzmaßnahmen
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
||||||
|
### 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) |
|
||||||
|
|
||||||
|
|
||||||
|
### 6. Rate Limiting (alle Endpunkte)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
||||||
|
## Aktuelle Kennzahlen
|
||||||
|
|
||||||
|
| Metrik | Wert |
|
||||||
|
| ------------------------ | ---- |
|
||||||
|
| Gesperrte Accounts | 0 |
|
||||||
|
| Unverifizierte Accounts | 4 |
|
||||||
|
| Gesendete Outreach-Mails | 9 |
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue